diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 180600cfed..3951cffb84 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -69,54 +69,6 @@ exports[`should create default config 1`] = ` "_maxListeners": undefined, Symbol(kCapture): false, }, - "experimental": { - "externalResolver": { - "getVariant": [Function], - "isEnabled": [Function], - }, - "flags": { - "accessOverview": false, - "anonymiseEventLog": false, - "banners": false, - "caseInsensitiveInOperators": false, - "customRootRolesKillSwitch": false, - "datadogJsonTemplate": false, - "demo": false, - "dependentFeatures": false, - "disableBulkToggle": false, - "disableEnvsOnRevive": false, - "disableMetrics": false, - "disableNotifications": false, - "doraMetrics": false, - "embedProxy": true, - "embedProxyFrontend": true, - "featureNamingPattern": false, - "featureSwitchRefactor": false, - "featuresExportImport": true, - "filterInvalidClientMetrics": false, - "googleAuthEnabled": false, - "lastSeenByEnvironment": false, - "maintenanceMode": false, - "messageBanner": { - "enabled": false, - "name": "message-banner", - "payload": { - "type": "json", - "value": "", - }, - }, - "migrationLock": true, - "personalAccessTokensKillSwitch": false, - "playgroundImprovements": false, - "privateProjects": false, - "proPlanAutoCharge": false, - "responseTimeWithAppNameKillSwitch": false, - "separateAdminClientApi": false, - "strictSchemaValidation": false, - "useLastSeenRefactor": false, - "variantTypeNumber": false, - }, - }, "flagResolver": FlagResolver { "experiments": { "accessOverview": false, @@ -135,6 +87,7 @@ exports[`should create default config 1`] = ` "embedProxy": true, "embedProxyFrontend": true, "featureNamingPattern": false, + "featureSearchAPI": false, "featureSwitchRefactor": false, "featuresExportImport": true, "filterInvalidClientMetrics": false, diff --git a/src/lib/create-config.test.ts b/src/lib/create-config.test.ts index 729b9d158d..38140e5f02 100644 --- a/src/lib/create-config.test.ts +++ b/src/lib/create-config.test.ts @@ -15,7 +15,8 @@ test('should create default config', async () => { }, }); - expect(config).toMatchSnapshot(); + const { experimental, ...configWithoutExperimental } = config; + expect(configWithoutExperimental).toMatchSnapshot(); }); test('should add initApiToken for admin token from options', async () => { diff --git a/src/lib/features/dependent-features/dependent-features-controller.ts b/src/lib/features/dependent-features/dependent-features-controller.ts index 8e56ceafe8..fd2042b8d7 100644 --- a/src/lib/features/dependent-features/dependent-features-controller.ts +++ b/src/lib/features/dependent-features/dependent-features-controller.ts @@ -20,11 +20,7 @@ import { import { IAuthRequest } from '../../routes/unleash-types'; import { InvalidOperationError } from '../../error'; import { DependentFeaturesService } from './dependent-features-service'; -import { - TransactionCreator, - UnleashTransaction, - WithTransactional, -} from '../../db/transaction'; +import { WithTransactional } from '../../db/transaction'; interface ProjectParams { projectId: string; @@ -72,7 +68,7 @@ export default class DependentFeaturesController extends Controller { this.openApiService = openApiService; this.flagResolver = config.flagResolver; this.logger = config.getLogger( - '/dependent-features/dependent-feature-service.ts', + '/dependent-features/dependent-features-controller.ts', ); this.route({ diff --git a/src/lib/features/dependent-features/dependent.features.e2e.test.ts b/src/lib/features/dependent-features/dependent.features.e2e.test.ts index 468a369062..d5cb4ad1b5 100644 --- a/src/lib/features/dependent-features/dependent.features.e2e.test.ts +++ b/src/lib/features/dependent-features/dependent.features.e2e.test.ts @@ -11,9 +11,7 @@ import { FEATURE_DEPENDENCY_ADDED, FEATURE_DEPENDENCY_REMOVED, IEventStore, - IUser, } from '../../types'; -import { ProjectService } from '../../services'; let app: IUnleashTest; let db: ITestDb; diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts new file mode 100644 index 0000000000..ae80cadb6d --- /dev/null +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -0,0 +1,76 @@ +import { Response } from 'express'; +import Controller from '../../routes/controller'; +import { OpenApiService } from '../../services'; +import { + IFlagResolver, + IUnleashConfig, + IUnleashServices, + NONE, +} from '../../types'; +import { Logger } from '../../logger'; +import { createResponseSchema, getStandardResponses } from '../../openapi'; +import { IAuthRequest } from '../../routes/unleash-types'; +import { InvalidOperationError } from '../../error'; + +interface ISearchQueryParams { + query: string; + tags: string[]; +} + +const PATH = '/features'; + +type FeatureSearchServices = Pick; + +export default class FeatureSearchController extends Controller { + private openApiService: OpenApiService; + + private flagResolver: IFlagResolver; + + private readonly logger: Logger; + + constructor( + config: IUnleashConfig, + { openApiService }: FeatureSearchServices, + ) { + super(config); + this.openApiService = openApiService; + this.flagResolver = config.flagResolver; + this.logger = config.getLogger( + '/feature-search/feature-search-controller.ts', + ); + + this.route({ + method: 'get', + path: PATH, + handler: this.searchFeatures, + permission: NONE, + middleware: [ + openApiService.validPath({ + tags: ['Search'], + summary: 'Search and filter features', + description: 'Search and filter by selected fields.', + operationId: 'searchFeatures', + responses: { + 200: createResponseSchema('searchFeaturesSchema'), + ...getStandardResponses(401, 403, 404), + }, + }), + ], + }); + } + + async searchFeatures( + req: IAuthRequest, + res: Response, + ): Promise { + const { query, tags } = req.query; + + if (this.config.flagResolver.isEnabled('featureSearchAPI')) { + res.json({ features: [] }); + } else { + throw new InvalidOperationError( + 'Feature Search API is not enabled', + ); + } + } +} diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts new file mode 100644 index 0000000000..a3cc0b6f81 --- /dev/null +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -0,0 +1,41 @@ +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'; + +let app: IUnleashTest; +let db: ITestDb; + +beforeAll(async () => { + db = await dbInit('feature_search', getLogger); + app = await setupAppWithCustomConfig( + db.stores, + { + experimental: { + flags: { + strictSchemaValidation: true, + featureSearchAPI: true, + }, + }, + }, + db.rawDatabase, + ); +}); + +afterAll(async () => { + await app.destroy(); + await db.destroy(); +}); + +beforeEach(async () => {}); + +const searchFeatures = async (expectedCode = 200) => { + return app.request.get(`/api/admin/search/features`).expect(expectedCode); +}; + +test('should return empty features', async () => { + const { body } = await searchFeatures(); + expect(body).toStrictEqual({ features: [] }); +}); diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 00be2ad6d5..8b8156a71e 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -166,6 +166,7 @@ import { parentFeatureOptionsSchema, dependenciesExistSchema, validateArchiveFeaturesSchema, + searchFeaturesSchema, } from './spec'; import { IServerOption } from '../types'; import { mapValues, omitKeys } from '../util'; @@ -395,6 +396,7 @@ export const schemas: UnleashSchemas = { featureDependenciesSchema, dependenciesExistSchema, validateArchiveFeaturesSchema, + searchFeaturesSchema, }; // Remove JSONSchema keys that would result in an invalid OpenAPI spec. diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts index 98b65383a8..eabbb0421d 100644 --- a/src/lib/openapi/spec/index.ts +++ b/src/lib/openapi/spec/index.ts @@ -166,3 +166,4 @@ export * from './parent-feature-options-schema'; export * from './feature-dependencies-schema'; export * from './dependencies-exist-schema'; export * from './validate-archive-features-schema'; +export * from './search-features-schema'; diff --git a/src/lib/openapi/spec/search-features-schema.ts b/src/lib/openapi/spec/search-features-schema.ts new file mode 100644 index 0000000000..b5369a0622 --- /dev/null +++ b/src/lib/openapi/spec/search-features-schema.ts @@ -0,0 +1,43 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { parametersSchema } from './parameters-schema'; +import { variantSchema } from './variant-schema'; +import { overrideSchema } from './override-schema'; +import { featureStrategySchema } from './feature-strategy-schema'; +import { featureSchema } from './feature-schema'; +import { constraintSchema } from './constraint-schema'; +import { featureEnvironmentSchema } from './feature-environment-schema'; +import { strategyVariantSchema } from './strategy-variant-schema'; +import { tagSchema } from './tag-schema'; + +export const searchFeaturesSchema = { + $id: '#/components/schemas/searchFeaturesSchema', + type: 'object', + additionalProperties: false, + required: ['features'], + description: 'A list of features matching search and filter criteria.', + properties: { + features: { + type: 'array', + items: { + $ref: '#/components/schemas/featureSchema', + }, + description: + 'The full list of features in this project (excluding archived features)', + }, + }, + components: { + schemas: { + featureSchema, + constraintSchema, + featureEnvironmentSchema, + featureStrategySchema, + strategyVariantSchema, + overrideSchema, + parametersSchema, + variantSchema, + tagSchema, + }, + }, +} as const; + +export type SearchFeaturesSchema = FromSchema; diff --git a/src/lib/openapi/util/openapi-tags.ts b/src/lib/openapi/util/openapi-tags.ts index 5070dc09d1..21d6e3352c 100644 --- a/src/lib/openapi/util/openapi-tags.ts +++ b/src/lib/openapi/util/openapi-tags.ts @@ -113,6 +113,7 @@ const OPENAPI_TAGS = [ description: 'Create, update, and delete [Unleash Public Signup tokens](https://docs.getunleash.io/reference/public-signup-tokens).', }, + { name: 'Search', description: 'Search for features.' }, { name: 'Segments', description: diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts index d995461ab1..c85927af66 100644 --- a/src/lib/routes/admin-api/index.ts +++ b/src/lib/routes/admin-api/index.ts @@ -33,6 +33,7 @@ import { createKnexTransactionStarter } from '../../db/transaction'; import { Db } from '../../db/db'; import ExportImportController from '../../features/export-import-toggles/export-import-controller'; import { SegmentsController } from '../../features/segment/segment-controller'; +import FeatureSearchController from '../../features/feature-search/feature-search-controller'; class AdminApi extends Controller { constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) { @@ -147,6 +148,11 @@ class AdminApi extends Controller { '/telemetry', new TelemetryController(config, services).router, ); + + this.app.use( + '/search', + new FeatureSearchController(config, services).router, + ); } } diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 41da53b747..8a26967151 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -36,7 +36,8 @@ export type IFlagKey = | 'separateAdminClientApi' | 'disableEnvsOnRevive' | 'playgroundImprovements' - | 'featureSwitchRefactor'; + | 'featureSwitchRefactor' + | 'featureSearchAPI'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -172,6 +173,10 @@ const flags: IFlags = { process.env.UNLEASH_EXPERIMENTAL_FEATURE_SWITCH_REFACTOR, false, ), + featureSearchAPI: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_FEATURE_SEARCH_API, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = {