diff --git a/frontend/src/component/common/flags.ts b/frontend/src/component/common/flags.ts
index d11ef76c12..923826a9a5 100644
--- a/frontend/src/component/common/flags.ts
+++ b/frontend/src/component/common/flags.ts
@@ -3,5 +3,4 @@ export const C = 'C';
export const E = 'E';
export const EEA = 'EEA';
export const RE = 'RE';
-export const SE = 'SE';
export const UG = 'UG';
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
index 43036006f2..ac1ce8c50c 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx
@@ -141,8 +141,7 @@ export const FeatureStrategyEdit = () => {
savedStrategySegments && setSegments(savedStrategySegments);
}, [JSON.stringify(savedStrategySegments)]);
- const segmentsToSubmit = uiConfig?.flags.SE ? segments : [];
- const payload = createStrategyPayload(strategy, segmentsToSubmit);
+ const payload = createStrategyPayload(strategy, segments);
const onStrategyEdit = async (payload: IFeatureStrategyPayload) => {
await updateStrategyOnFeature(
diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
index caf3e68b72..d9d732a962 100644
--- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
+++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx
@@ -230,15 +230,10 @@ export const FeatureStrategyForm = ({
}));
}}
/>
-
- }
+
= ({
}
const listItems = [
- Boolean(uiConfig.flags.SE) &&
- strategySegments &&
- strategySegments.length > 0 && (
-
- ),
+ strategySegments && strategySegments.length > 0 && (
+
+ ),
constraints.length > 0 && (
= ({
}) => {
const { name, constraints, segments, parameters } = strategyResult;
- const { uiConfig } = useUiConfig();
-
- const hasSegments =
- Boolean(uiConfig.flags.SE) && Boolean(segments && segments.length > 0);
+ const hasSegments = Boolean(segments && segments.length > 0);
const hasConstraints = Boolean(constraints && constraints?.length > 0);
const hasExecutionParameters =
name !== 'default' &&
diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx
index 8b644373ae..4547a59f73 100644
--- a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx
+++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy.tsx
@@ -86,11 +86,7 @@ const EditDefaultStrategy = () => {
}
}, [JSON.stringify(allSegments), JSON.stringify(strategy?.segments)]);
- const segmentsToSubmit = uiConfig?.flags.SE ? segments : [];
- const payload = createStrategyPayload(
- defaultStrategy as any,
- segmentsToSubmit
- );
+ const payload = createStrategyPayload(defaultStrategy as any, segments);
const onDefaultStrategyEdit = async (
payload: CreateFeatureStrategySchema
diff --git a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx
index ace7f08ffe..5620dd940e 100644
--- a/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx
+++ b/frontend/src/component/project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/ProjectDefaultStrategyForm.tsx
@@ -155,15 +155,10 @@ export const ProjectDefaultStrategyForm = ({
}));
}}
/>
-
- }
+
{Array.from(projectsUsed).map(projectId => (
@@ -87,17 +88,6 @@ export const SegmentProjectAlert = ({
);
}
- if (availableProjects.length === 1) {
- return (
-
- You can't specify a project other than{' '}
- {availableProjects[0].name} for this segment
- because it is used here:
- {projectList}
-
- );
- }
-
return null;
};
diff --git a/frontend/src/hooks/api/getters/useSegments/useSegments.ts b/frontend/src/hooks/api/getters/useSegments/useSegments.ts
index 4bd67860ba..dbbe64452e 100644
--- a/frontend/src/hooks/api/getters/useSegments/useSegments.ts
+++ b/frontend/src/hooks/api/getters/useSegments/useSegments.ts
@@ -1,9 +1,9 @@
import { useCallback } from 'react';
+import useSWR, { mutate } from 'swr';
import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler';
import { ISegment } from 'interfaces/segment';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
-import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR';
export interface IUseSegmentsOutput {
segments?: ISegment[];
@@ -19,15 +19,9 @@ export const useSegments = (strategyId?: string): IUseSegmentsOutput => {
? formatApiPath(`api/admin/segments/strategies/${strategyId}`)
: formatApiPath('api/admin/segments');
- const { data, error, mutate } = useConditionalSWR(
- Boolean(uiConfig.flags?.SE),
- [],
- url,
- () => fetchSegments(url),
- {
- refreshInterval: 15 * 1000,
- }
- );
+ const { data, error, mutate } = useSWR(url, () => fetchSegments(url), {
+ refreshInterval: 15 * 1000,
+ });
const refetchSegments = useCallback(() => {
mutate().catch(console.warn);
diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts
index a855b8bb52..e4438d0d4b 100644
--- a/frontend/src/interfaces/uiConfig.ts
+++ b/frontend/src/interfaces/uiConfig.ts
@@ -40,7 +40,6 @@ export type UiFlags = {
P: boolean;
RE: boolean;
EEA?: boolean;
- SE?: boolean;
T?: boolean;
UNLEASH_CLOUD?: boolean;
UG?: boolean;
diff --git a/src/lib/features/segment/segment-controller.ts b/src/lib/features/segment/segment-controller.ts
new file mode 100644
index 0000000000..23d8e7ab4d
--- /dev/null
+++ b/src/lib/features/segment/segment-controller.ts
@@ -0,0 +1,468 @@
+import { Request, Response } from 'express';
+import Controller from '../../routes/controller';
+
+import {
+ IAuthRequest,
+ IUnleashConfig,
+ IUnleashServices,
+ Logger,
+} from '../../server-impl';
+import {
+ AdminSegmentSchema,
+ UpdateFeatureStrategySegmentsSchema,
+ UpsertSegmentSchema,
+ adminSegmentSchema,
+ createRequestSchema,
+ createResponseSchema,
+ resourceCreatedResponseSchema,
+ updateFeatureStrategySchema,
+} from '../../openapi';
+import {
+ emptyResponse,
+ getStandardResponses,
+} from '../../openapi/util/standard-responses';
+import { ISegmentService } from '../../segments/segment-service-interface';
+import { SegmentStrategiesSchema } from '../../openapi/spec/admin-strategies-schema';
+import { AccessService, OpenApiService } from '../../services';
+import {
+ CREATE_SEGMENT,
+ DELETE_SEGMENT,
+ IFlagResolver,
+ NONE,
+ UPDATE_FEATURE_STRATEGY,
+ UPDATE_SEGMENT,
+ serializeDates,
+} from '../../types';
+import {
+ segmentsSchema,
+ SegmentsSchema,
+} from '../../openapi/spec/segments-schema';
+
+import { anonymiseKeys } from '../../util';
+import { BadDataError } from '../../error';
+
+type IUpdateFeatureStrategySegmentsRequest = IAuthRequest<
+ {},
+ undefined,
+ UpdateFeatureStrategySegmentsSchema
+>;
+
+export class SegmentsController extends Controller {
+ private logger: Logger;
+
+ private segmentService: ISegmentService;
+
+ private accessService: AccessService;
+
+ private flagResolver: IFlagResolver;
+
+ private openApiService: OpenApiService;
+
+ constructor(
+ config: IUnleashConfig,
+ {
+ segmentService,
+ accessService,
+ openApiService,
+ }: Pick<
+ IUnleashServices,
+ 'segmentService' | 'accessService' | 'openApiService'
+ >,
+ ) {
+ super(config);
+ this.flagResolver = config.flagResolver;
+ this.config = config;
+ this.logger = config.getLogger('/admin-api/segments.ts');
+ this.segmentService = segmentService;
+ this.accessService = accessService;
+ this.openApiService = openApiService;
+
+ this.route({
+ method: 'post',
+ path: '/validate',
+ handler: this.validate,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Validates if a segment name exists',
+ description:
+ 'Uses the name provided in the body of the request to validate if the given name exists or not',
+ tags: ['Segments'],
+ operationId: 'validateSegment',
+ requestBody: createRequestSchema('nameSchema'),
+ responses: {
+ 204: emptyResponse,
+ ...getStandardResponses(400, 401, 409, 415),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'get',
+ path: '/strategies/:strategyId',
+ handler: this.getSegmentsByStrategy,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ tags: ['Segments'],
+ operationId: 'getSegmentsByStrategyId',
+ summary: 'Get strategy segments',
+ description:
+ "Retrieve all segments that are referenced by the specified strategy. Returns an empty list of segments if the strategy ID doesn't exist.",
+ responses: {
+ 200: createResponseSchema('segmentsSchema'),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'post',
+ path: '/strategies',
+ handler: this.updateFeatureStrategySegments,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Update strategy segments',
+ description:
+ 'Sets the segments of the strategy specified to be exactly the ones passed in the payload. Any segments that were used by the strategy before will be removed if they are not in the provided list of segments.',
+ tags: ['Strategies'],
+ operationId: 'updateFeatureStrategySegments',
+ requestBody: createRequestSchema(
+ 'updateFeatureStrategySegmentsSchema',
+ ),
+ responses: {
+ 201: resourceCreatedResponseSchema(
+ 'updateFeatureStrategySegmentsSchema',
+ ),
+ ...getStandardResponses(400, 401, 403, 415),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'get',
+ path: '/:id/strategies',
+ handler: this.getStrategiesBySegment,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ tags: ['Segments'],
+ operationId: 'getStrategiesBySegmentId',
+ summary: 'Get strategies that reference segment',
+ description:
+ 'Retrieve all strategies that reference the specified segment.',
+ responses: {
+ 200: createResponseSchema('adminStrategiesSchema'),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'delete',
+ path: '/:id',
+ handler: this.removeSegment,
+ permission: DELETE_SEGMENT,
+ acceptAnyContentType: true,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Deletes a segment by id',
+ description:
+ 'Deletes a segment by its id, if not found returns a 409 error',
+ tags: ['Segments'],
+ operationId: 'removeSegment',
+ responses: {
+ 204: emptyResponse,
+ ...getStandardResponses(401, 403, 409),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'put',
+ path: '/:id',
+ handler: this.updateSegment,
+ permission: UPDATE_SEGMENT,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Update segment by id',
+ description:
+ 'Updates the content of the segment with the provided payload. Any fields not specified will be left untouched.',
+ tags: ['Segments'],
+ operationId: 'updateSegment',
+ responses: {
+ 204: emptyResponse,
+ ...getStandardResponses(400, 401, 403, 409, 415),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'get',
+ path: '/:id',
+ handler: this.getSegment,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Get a segment',
+ description: 'Retrieves a segment based on its ID.',
+ tags: ['Segments'],
+ operationId: 'getSegment',
+ responses: {
+ 200: createResponseSchema('adminSegmentSchema'),
+ ...getStandardResponses(404),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'post',
+ path: '',
+ handler: this.createSegment,
+ permission: CREATE_SEGMENT,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Create a new segment',
+ description:
+ 'Creates a new segment using the payload provided',
+ tags: ['Segments'],
+ operationId: 'createSegment',
+ requestBody: createRequestSchema('upsertSegmentSchema'),
+ responses: {
+ 201: resourceCreatedResponseSchema(
+ 'adminSegmentSchema',
+ ),
+ ...getStandardResponses(400, 401, 403, 409, 415),
+ },
+ }),
+ ],
+ });
+
+ this.route({
+ method: 'get',
+ path: '',
+ handler: this.getSegments,
+ permission: NONE,
+ middleware: [
+ openApiService.validPath({
+ summary: 'Get all segments',
+ description:
+ 'Retrieves all segments that exist in this Unleash instance.',
+ tags: ['Segments'],
+ operationId: 'getSegments',
+ responses: {
+ 200: createResponseSchema('segmentsSchema'),
+ },
+ }),
+ ],
+ });
+ }
+
+ async validate(
+ req: Request,
+ res: Response,
+ ): Promise {
+ const { name } = req.body;
+ await this.segmentService.validateName(name);
+ res.status(204).send();
+ }
+
+ async getSegmentsByStrategy(
+ req: Request<{ strategyId: string }>,
+ res: Response,
+ ): Promise {
+ const { strategyId } = req.params;
+ const segments = await this.segmentService.getByStrategy(strategyId);
+
+ const responseBody = this.flagResolver.isEnabled('anonymiseEventLog')
+ ? {
+ segments: anonymiseKeys(segments, ['createdBy']),
+ }
+ : { segments };
+
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ segmentsSchema.$id,
+ serializeDates(responseBody),
+ );
+ }
+
+ async updateFeatureStrategySegments(
+ req: IUpdateFeatureStrategySegmentsRequest,
+ res: Response,
+ ): Promise {
+ const { projectId, environmentId, strategyId, segmentIds } = req.body;
+
+ const hasFeatureStrategyPermission = this.accessService.hasPermission(
+ req.user,
+ UPDATE_FEATURE_STRATEGY,
+ projectId,
+ environmentId,
+ );
+
+ if (!hasFeatureStrategyPermission) {
+ res.status(403).send();
+ return;
+ }
+
+ if (segmentIds.length > this.config.strategySegmentsLimit) {
+ throw new BadDataError(
+ `Strategies may not have more than ${this.config.strategySegmentsLimit} segments`,
+ );
+ }
+
+ const segments = await this.segmentService.getByStrategy(strategyId);
+ const currentSegmentIds = segments.map((segment) => segment.id);
+
+ await this.removeFromStrategy(
+ strategyId,
+ currentSegmentIds.filter((id) => !segmentIds.includes(id)),
+ );
+
+ await this.addToStrategy(
+ strategyId,
+ segmentIds.filter((id) => !currentSegmentIds.includes(id)),
+ );
+
+ this.openApiService.respondWithValidation(
+ 201,
+ res,
+ updateFeatureStrategySchema.$id,
+ req.body,
+ {
+ location: `strategies/${strategyId}`,
+ },
+ );
+ }
+
+ async getStrategiesBySegment(
+ req: IAuthRequest<{ id: number }>,
+ res: Response,
+ ): Promise {
+ const { id } = req.params;
+ const strategies = await this.segmentService.getStrategies(id);
+
+ // Remove unnecessary IFeatureStrategy fields from the response.
+ const segmentStrategies = strategies.map((strategy) => ({
+ id: strategy.id,
+ projectId: strategy.projectId,
+ featureName: strategy.featureName,
+ strategyName: strategy.strategyName,
+ environment: strategy.environment,
+ }));
+
+ res.json({
+ strategies: segmentStrategies,
+ });
+ }
+
+ async removeSegment(
+ req: IAuthRequest<{ id: string }>,
+ res: Response,
+ ): Promise {
+ const id = Number(req.params.id);
+ const strategies = await this.segmentService.getStrategies(id);
+
+ if (strategies.length > 0) {
+ res.status(409).send();
+ } else {
+ await this.segmentService.delete(id, req.user);
+ res.status(204).send();
+ }
+ }
+
+ async updateSegment(
+ req: IAuthRequest<{ id: string }>,
+ res: Response,
+ ): Promise {
+ const id = Number(req.params.id);
+ const updateRequest: UpsertSegmentSchema = {
+ name: req.body.name,
+ description: req.body.description,
+ project: req.body.project,
+ constraints: req.body.constraints,
+ };
+ await this.segmentService.update(id, updateRequest, req.user);
+ res.status(204).send();
+ }
+
+ async getSegment(
+ req: Request<{ id: string }>,
+ res: Response,
+ ): Promise {
+ const id = Number(req.params.id);
+ const segment = await this.segmentService.get(id);
+ if (this.flagResolver.isEnabled('anonymiseEventLog')) {
+ res.json(anonymiseKeys(segment, ['createdBy']));
+ } else {
+ res.json(segment);
+ }
+ }
+
+ async createSegment(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ const createRequest = req.body;
+ const segment = await this.segmentService.create(
+ createRequest,
+ req.user,
+ );
+ this.openApiService.respondWithValidation(
+ 201,
+ res,
+ adminSegmentSchema.$id,
+ serializeDates(segment),
+ { location: `segments/${segment.id}` },
+ );
+ }
+
+ async getSegments(
+ req: IAuthRequest,
+ res: Response,
+ ): Promise {
+ const segments = await this.segmentService.getAll();
+
+ const response = {
+ segments: this.flagResolver.isEnabled('anonymiseEventLog')
+ ? anonymiseKeys(segments, ['createdBy'])
+ : segments,
+ };
+
+ this.openApiService.respondWithValidation(
+ 200,
+ res,
+ segmentsSchema.$id,
+ serializeDates(response),
+ );
+ }
+
+ private async removeFromStrategy(
+ strategyId: string,
+ segmentIds: number[],
+ ): Promise {
+ await Promise.all(
+ segmentIds.map((id) =>
+ this.segmentService.removeFromStrategy(id, strategyId),
+ ),
+ );
+ }
+
+ private async addToStrategy(
+ strategyId: string,
+ segmentIds: number[],
+ ): Promise {
+ await Promise.all(
+ segmentIds.map((id) =>
+ this.segmentService.addToStrategy(id, strategyId),
+ ),
+ );
+ }
+}
diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts
index 9e709fdf5f..f19ac515e2 100644
--- a/src/lib/openapi/index.ts
+++ b/src/lib/openapi/index.ts
@@ -6,6 +6,7 @@ import {
addonsSchema,
addonTypeSchema,
adminCountSchema,
+ adminSegmentSchema,
adminFeaturesQuerySchema,
advancedPlaygroundRequestSchema,
advancedPlaygroundResponseSchema,
@@ -158,6 +159,8 @@ import {
createGroupSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
+ segmentsSchema,
+ updateFeatureStrategySegmentsSchema,
dependentFeatureSchema,
createDependentFeatureSchema,
} from './spec';
@@ -177,6 +180,7 @@ import { createApplicationSchema } from './spec/create-application-schema';
import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema';
import { advancedPlaygroundEnvironmentFeatureSchema } from './spec/advanced-playground-environment-feature-schema';
import { createFeatureNamingPatternSchema } from './spec/create-feature-naming-pattern-schema';
+import { segmentStrategiesSchema } from './spec/admin-strategies-schema';
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
@@ -210,6 +214,8 @@ interface OpenAPIV3DocumentWithServers extends OpenAPIV3.Document {
export const schemas: UnleashSchemas = {
adminCountSchema,
adminFeaturesQuerySchema,
+ adminSegmentSchema,
+ adminStrategiesSchema: segmentStrategiesSchema,
addonParameterSchema,
addonSchema,
addonCreateUpdateSchema,
@@ -377,6 +383,8 @@ export const schemas: UnleashSchemas = {
createFeatureNamingPatternSchema,
doraFeaturesSchema,
projectDoraMetricsSchema,
+ segmentsSchema,
+ updateFeatureStrategySegmentsSchema,
dependentFeatureSchema,
createDependentFeatureSchema,
};
diff --git a/src/lib/openapi/spec/__snapshots__/admin-segment-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/admin-segment-schema.test.ts.snap
new file mode 100644
index 0000000000..0fd9b1c625
--- /dev/null
+++ b/src/lib/openapi/spec/__snapshots__/admin-segment-schema.test.ts.snap
@@ -0,0 +1,54 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`updateEnvironmentSchema 1`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "additionalProperties",
+ "message": "must NOT have additional properties",
+ "params": {
+ "additionalProperty": "additional",
+ },
+ "schemaPath": "#/additionalProperties",
+ },
+ ],
+ "schema": "#/components/schemas/adminSegmentSchema",
+}
+`;
+
+exports[`updateEnvironmentSchema 2`] = `undefined`;
+
+exports[`updateEnvironmentSchema 3`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'id'",
+ "params": {
+ "missingProperty": "id",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/adminSegmentSchema",
+}
+`;
+
+exports[`updateEnvironmentSchema 4`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "type",
+ "message": "must be object",
+ "params": {
+ "type": "object",
+ },
+ "schemaPath": "#/type",
+ },
+ ],
+ "schema": "#/components/schemas/adminSegmentSchema",
+}
+`;
diff --git a/src/lib/openapi/spec/__snapshots__/admin-strategies-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/admin-strategies-schema.test.ts.snap
new file mode 100644
index 0000000000..3d26348c63
--- /dev/null
+++ b/src/lib/openapi/spec/__snapshots__/admin-strategies-schema.test.ts.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`segmentStrategiesSchema 1`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "type",
+ "message": "must be object",
+ "params": {
+ "type": "object",
+ },
+ "schemaPath": "#/type",
+ },
+ ],
+ "schema": "#/components/schemas/segmentStrategiesSchema",
+}
+`;
+
+exports[`segmentStrategiesSchema 2`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'strategies'",
+ "params": {
+ "missingProperty": "strategies",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/segmentStrategiesSchema",
+}
+`;
+
+exports[`segmentStrategiesSchema 3`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'strategies'",
+ "params": {
+ "missingProperty": "strategies",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/segmentStrategiesSchema",
+}
+`;
+
+exports[`segmentStrategiesSchema 4`] = `
+{
+ "errors": [
+ {
+ "instancePath": "/strategies/0",
+ "keyword": "required",
+ "message": "must have required property 'id'",
+ "params": {
+ "missingProperty": "id",
+ },
+ "schemaPath": "#/properties/strategies/items/required",
+ },
+ ],
+ "schema": "#/components/schemas/segmentStrategiesSchema",
+}
+`;
diff --git a/src/lib/openapi/spec/__snapshots__/segments-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/segments-schema.test.ts.snap
new file mode 100644
index 0000000000..0cafd84ecf
--- /dev/null
+++ b/src/lib/openapi/spec/__snapshots__/segments-schema.test.ts.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`updateEnvironmentSchema 1`] = `undefined`;
+
+exports[`updateEnvironmentSchema 2`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "type",
+ "message": "must be object",
+ "params": {
+ "type": "object",
+ },
+ "schemaPath": "#/type",
+ },
+ ],
+ "schema": "#/components/schemas/segmentsSchema",
+}
+`;
diff --git a/src/lib/openapi/spec/__snapshots__/update-feature-strategy-segments-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/update-feature-strategy-segments-schema.test.ts.snap
new file mode 100644
index 0000000000..fadf5cda69
--- /dev/null
+++ b/src/lib/openapi/spec/__snapshots__/update-feature-strategy-segments-schema.test.ts.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`updateFeatureStrategySegmentsSchema schema 1`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'projectId'",
+ "params": {
+ "missingProperty": "projectId",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/updateFeatureStrategySegmentsSchema",
+}
+`;
+
+exports[`updateFeatureStrategySegmentsSchema schema 2`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'projectId'",
+ "params": {
+ "missingProperty": "projectId",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/updateFeatureStrategySegmentsSchema",
+}
+`;
diff --git a/src/lib/openapi/spec/__snapshots__/upsert-segment-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/upsert-segment-schema.test.ts.snap
new file mode 100644
index 0000000000..73279d7ff9
--- /dev/null
+++ b/src/lib/openapi/spec/__snapshots__/upsert-segment-schema.test.ts.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`upsertSegmentSchema 1`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'constraints'",
+ "params": {
+ "missingProperty": "constraints",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/upsertSegmentSchema",
+}
+`;
+
+exports[`upsertSegmentSchema 2`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'name'",
+ "params": {
+ "missingProperty": "name",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/upsertSegmentSchema",
+}
+`;
+
+exports[`upsertSegmentSchema 3`] = `
+{
+ "errors": [
+ {
+ "instancePath": "",
+ "keyword": "required",
+ "message": "must have required property 'name'",
+ "params": {
+ "missingProperty": "name",
+ },
+ "schemaPath": "#/required",
+ },
+ ],
+ "schema": "#/components/schemas/upsertSegmentSchema",
+}
+`;
diff --git a/src/lib/openapi/spec/admin-segment-schema.test.ts b/src/lib/openapi/spec/admin-segment-schema.test.ts
new file mode 100644
index 0000000000..89363b1f78
--- /dev/null
+++ b/src/lib/openapi/spec/admin-segment-schema.test.ts
@@ -0,0 +1,59 @@
+import { validateSchema } from '../validate';
+import { AdminSegmentSchema } from './admin-segment-schema';
+
+test('updateEnvironmentSchema', () => {
+ const data: AdminSegmentSchema = {
+ id: 1,
+ name: 'release',
+ constraints: [],
+ createdAt: '2022-07-25 06:00:00',
+ createdBy: 'test',
+ description: 'a description',
+ };
+
+ expect(
+ validateSchema('#/components/schemas/adminSegmentSchema', data),
+ ).toBeUndefined();
+
+ expect(
+ validateSchema('#/components/schemas/adminSegmentSchema', {
+ id: 1,
+ name: 'release',
+ constraints: [],
+ createdAt: '2022-07-25 06:00:00',
+ }),
+ ).toBeUndefined();
+
+ expect(
+ validateSchema('#/components/schemas/adminSegmentSchema', {
+ id: 1,
+ name: 'release',
+ constraints: [],
+ createdAt: '2022-07-25 06:00:00',
+ additional: 'property',
+ }),
+ ).toMatchSnapshot();
+
+ expect(
+ validateSchema('#/components/schemas/adminSegmentSchema', {
+ id: 1,
+ name: 'release',
+ constraints: [],
+ createdAt: 'wrong-format',
+ }),
+ ).toMatchSnapshot();
+
+ expect(
+ validateSchema('#/components/schemas/adminSegmentSchema', {
+ name: 'release',
+ constraints: [],
+ }),
+ ).toMatchSnapshot();
+
+ expect(
+ validateSchema(
+ '#/components/schemas/adminSegmentSchema',
+ 'not an object',
+ ),
+ ).toMatchSnapshot();
+});
diff --git a/src/lib/openapi/spec/admin-segment-schema.ts b/src/lib/openapi/spec/admin-segment-schema.ts
new file mode 100644
index 0000000000..cb6a2db128
--- /dev/null
+++ b/src/lib/openapi/spec/admin-segment-schema.ts
@@ -0,0 +1,78 @@
+import { FromSchema } from 'json-schema-to-ts';
+import { constraintSchema } from './constraint-schema';
+
+export const adminSegmentSchema = {
+ $id: '#/components/schemas/adminSegmentSchema',
+ type: 'object',
+ required: ['id', 'name', 'constraints', 'createdAt'],
+ description:
+ 'A description of a [segment](https://docs.getunleash.io/reference/segments)',
+ additionalProperties: false,
+ properties: {
+ id: {
+ type: 'integer',
+ description: 'The ID of this segment',
+ example: 2,
+ minimum: 0,
+ },
+ name: {
+ type: 'string',
+ description: 'The name of this segment',
+ example: 'ios-users',
+ },
+ description: {
+ type: 'string',
+ nullable: true,
+ description: 'The description for this segment',
+ example: 'IOS users segment',
+ },
+ constraints: {
+ type: 'array',
+ description:
+ 'The list of constraints that are used in this segment',
+ items: {
+ $ref: '#/components/schemas/constraintSchema',
+ },
+ },
+ usedInFeatures: {
+ type: 'integer',
+ minimum: 0,
+ description: 'The number of projects that use this segment',
+ example: 3,
+ nullable: true,
+ },
+ usedInProjects: {
+ type: 'integer',
+ minimum: 0,
+ description: 'The number of projects that use this segment',
+ example: 2,
+ nullable: true,
+ },
+ project: {
+ type: 'string',
+ nullable: true,
+ example: 'red-vista',
+ description:
+ 'The project the segment belongs to. Only present if the segment is a project-specific segment.',
+ },
+ createdBy: {
+ description: "The creator's email or username",
+ example: 'someone@example.com',
+ type: 'string',
+ nullable: true,
+ },
+ createdAt: {
+ type: 'string',
+ format: 'date-time',
+ description: 'When the segment was created',
+ example: '2023-04-12T11:13:31.960Z',
+ },
+ },
+ components: {
+ schemas: {
+ constraintSchema,
+ },
+ },
+} as const;
+
+export type AdminSegmentSchema = FromSchema;
diff --git a/src/lib/openapi/spec/admin-strategies-schema.test.ts b/src/lib/openapi/spec/admin-strategies-schema.test.ts
new file mode 100644
index 0000000000..bd92fe15dc
--- /dev/null
+++ b/src/lib/openapi/spec/admin-strategies-schema.test.ts
@@ -0,0 +1,43 @@
+import { validateSchema } from '../validate';
+
+test('segmentStrategiesSchema', () => {
+ const validExamples = [
+ { strategies: [] },
+ {
+ strategies: [
+ {
+ id: 'test',
+ projectId: '2',
+ featureName: 'featureName',
+ strategyName: 'strategyName',
+ environment: 'environment',
+ },
+ ],
+ },
+ ];
+ validExamples.forEach((obj) => {
+ expect(
+ validateSchema('#/components/schemas/segmentStrategiesSchema', obj),
+ ).toBeUndefined();
+ });
+
+ const invalidExamples = [
+ 'not an object',
+ {},
+ { notStrategies: [] },
+ {
+ strategies: [
+ {
+ featureName: 'featureName',
+ strategyName: 'strategyName',
+ environment: 'environment',
+ },
+ ],
+ },
+ ];
+ invalidExamples.forEach((obj) => {
+ expect(
+ validateSchema('#/components/schemas/segmentStrategiesSchema', obj),
+ ).toMatchSnapshot();
+ });
+});
diff --git a/src/lib/openapi/spec/admin-strategies-schema.ts b/src/lib/openapi/spec/admin-strategies-schema.ts
new file mode 100644
index 0000000000..e6124d83e1
--- /dev/null
+++ b/src/lib/openapi/spec/admin-strategies-schema.ts
@@ -0,0 +1,58 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const segmentStrategiesSchema = {
+ $id: '#/components/schemas/segmentStrategiesSchema',
+ type: 'object',
+ required: ['strategies'],
+ description: 'A collection of strategies belonging to a specified segment.',
+ properties: {
+ strategies: {
+ description: 'The list of strategies',
+ type: 'array',
+ items: {
+ type: 'object',
+ required: [
+ 'id',
+ 'featureName',
+ 'projectId',
+ 'environment',
+ 'strategyName',
+ ],
+ properties: {
+ id: {
+ type: 'string',
+ description: 'The ID of the strategy',
+ example: 'e465c813-cffb-4232-b184-82b1d6fe9d3d',
+ },
+ featureName: {
+ type: 'string',
+ description: 'The ID of the strategy',
+ example: 'new-signup-flow',
+ },
+ projectId: {
+ type: 'string',
+ description:
+ 'The ID of the project that the strategy belongs to.',
+ example: 'red-vista',
+ },
+ environment: {
+ type: 'string',
+ description:
+ 'The ID of the environment that the strategy belongs to.',
+ example: 'development',
+ },
+ strategyName: {
+ type: 'string',
+ description: "The name of the strategy's type.",
+ example: 'flexibleRollout',
+ },
+ },
+ },
+ },
+ },
+ components: {},
+} as const;
+
+export type SegmentStrategiesSchema = FromSchema<
+ typeof segmentStrategiesSchema
+>;
diff --git a/src/lib/openapi/spec/context-field-strategies-schema.ts b/src/lib/openapi/spec/context-field-strategies-schema.ts
index 75b3d77065..4703a2bcb5 100644
--- a/src/lib/openapi/spec/context-field-strategies-schema.ts
+++ b/src/lib/openapi/spec/context-field-strategies-schema.ts
@@ -1,7 +1,7 @@
import { FromSchema } from 'json-schema-to-ts';
export const contextFieldStrategiesSchema = {
- $id: '#/components/schemas/segmentStrategiesSchema',
+ $id: '#/components/schemas/contextFieldStrategiesSchema',
type: 'object',
description:
'A wrapper object containing all strategies that use a specific context field',
diff --git a/src/lib/openapi/spec/index.ts b/src/lib/openapi/spec/index.ts
index 84792ba324..8a323fbe94 100644
--- a/src/lib/openapi/spec/index.ts
+++ b/src/lib/openapi/spec/index.ts
@@ -157,5 +157,8 @@ export * from './create-group-schema';
export * from './application-usage-schema';
export * from './dora-features-schema';
export * from './project-dora-metrics-schema';
+export * from './admin-segment-schema';
+export * from './segments-schema';
+export * from './update-feature-strategy-segments-schema';
export * from './dependent-feature-schema';
export * from './create-dependent-feature-schema';
diff --git a/src/lib/openapi/spec/segments-schema.test.ts b/src/lib/openapi/spec/segments-schema.test.ts
new file mode 100644
index 0000000000..14dee1b976
--- /dev/null
+++ b/src/lib/openapi/spec/segments-schema.test.ts
@@ -0,0 +1,27 @@
+import { validateSchema } from '../validate';
+import { SegmentsSchema } from './segments-schema';
+
+test('updateEnvironmentSchema', () => {
+ const data: SegmentsSchema = {
+ segments: [],
+ };
+
+ expect(
+ validateSchema('#/components/schemas/segmentsSchema', data),
+ ).toBeUndefined();
+
+ expect(
+ validateSchema('#/components/schemas/segmentsSchema', {
+ segments: [],
+ additional: 'property',
+ }),
+ ).toBeUndefined();
+
+ expect(
+ validateSchema('#/components/schemas/segmentsSchema', {}),
+ ).toMatchSnapshot();
+
+ expect(
+ validateSchema('#/components/schemas/segmentsSchema', 'not an object'),
+ ).toMatchSnapshot();
+});
diff --git a/src/lib/openapi/spec/segments-schema.ts b/src/lib/openapi/spec/segments-schema.ts
new file mode 100644
index 0000000000..bb59413a66
--- /dev/null
+++ b/src/lib/openapi/spec/segments-schema.ts
@@ -0,0 +1,27 @@
+import { FromSchema } from 'json-schema-to-ts';
+import { adminSegmentSchema } from './admin-segment-schema';
+import { constraintSchema } from './constraint-schema';
+
+export const segmentsSchema = {
+ $id: '#/components/schemas/segmentsSchema',
+ description:
+ 'Data containing a list of [segments](https://docs.getunleash.io/reference/segments)',
+ type: 'object',
+ properties: {
+ segments: {
+ type: 'array',
+ description: 'A list of segments',
+ items: {
+ $ref: '#/components/schemas/adminSegmentSchema',
+ },
+ },
+ },
+ components: {
+ schemas: {
+ adminSegmentSchema,
+ constraintSchema,
+ },
+ },
+} as const;
+
+export type SegmentsSchema = FromSchema;
diff --git a/src/lib/openapi/spec/update-feature-strategy-segments-schema.test.ts b/src/lib/openapi/spec/update-feature-strategy-segments-schema.test.ts
new file mode 100644
index 0000000000..5c0d1e6794
--- /dev/null
+++ b/src/lib/openapi/spec/update-feature-strategy-segments-schema.test.ts
@@ -0,0 +1,36 @@
+import { validateSchema } from '../validate';
+import { UpdateFeatureStrategySegmentsSchema } from './update-feature-strategy-segments-schema';
+
+test('updateFeatureStrategySegmentsSchema schema', () => {
+ const data: UpdateFeatureStrategySegmentsSchema = {
+ strategyId: '1',
+ segmentIds: [1, 2],
+ projectId: 'default',
+ environmentId: 'default',
+ additional: 'property',
+ };
+
+ expect(
+ validateSchema(
+ '#/components/schemas/updateFeatureStrategySegmentsSchema',
+ data,
+ ),
+ ).toBeUndefined();
+
+ expect(
+ validateSchema(
+ '#/components/schemas/updateFeatureStrategySegmentsSchema',
+ {},
+ ),
+ ).toMatchSnapshot();
+
+ expect(
+ validateSchema(
+ '#/components/schemas/updateFeatureStrategySegmentsSchema',
+ {
+ strategyId: '1',
+ segmentIds: [],
+ },
+ ),
+ ).toMatchSnapshot();
+});
diff --git a/src/lib/openapi/spec/update-feature-strategy-segments-schema.ts b/src/lib/openapi/spec/update-feature-strategy-segments-schema.ts
new file mode 100644
index 0000000000..48cb86d886
--- /dev/null
+++ b/src/lib/openapi/spec/update-feature-strategy-segments-schema.ts
@@ -0,0 +1,40 @@
+import { FromSchema } from 'json-schema-to-ts';
+
+export const updateFeatureStrategySegmentsSchema = {
+ $id: '#/components/schemas/updateFeatureStrategySegmentsSchema',
+ type: 'object',
+ required: ['projectId', 'strategyId', 'environmentId', 'segmentIds'],
+ description: 'Data required to update segments for a strategy.',
+ properties: {
+ projectId: {
+ type: 'string',
+ description: 'The ID of the project that the strategy belongs to.',
+ example: 'red-vista',
+ },
+ strategyId: {
+ type: 'string',
+ description: 'The ID of the strategy to update segments for.',
+ example: '15d1e20b-6310-4e17-a0c2-9fb84de3053a',
+ },
+ environmentId: {
+ type: 'string',
+ description: 'The ID of the strategy environment.',
+ example: 'development',
+ },
+ segmentIds: {
+ type: 'array',
+ description:
+ 'The new list of segments (IDs) to use for this strategy. Any segments not in this list will be removed from the strategy.',
+ example: [1, 5, 6],
+ items: {
+ type: 'integer',
+ minimum: 0,
+ },
+ },
+ },
+ components: {},
+} as const;
+
+export type UpdateFeatureStrategySegmentsSchema = FromSchema<
+ typeof updateFeatureStrategySegmentsSchema
+>;
diff --git a/src/lib/openapi/spec/upsert-segment-schema.test.ts b/src/lib/openapi/spec/upsert-segment-schema.test.ts
new file mode 100644
index 0000000000..4342baf5d5
--- /dev/null
+++ b/src/lib/openapi/spec/upsert-segment-schema.test.ts
@@ -0,0 +1,44 @@
+import { validateSchema } from '../validate';
+
+test('upsertSegmentSchema', () => {
+ const validObjects = [
+ {
+ name: 'segment',
+ constraints: [],
+ },
+ {
+ name: 'segment',
+ description: 'description',
+ constraints: [],
+ },
+ {
+ name: 'segment',
+ description: 'description',
+ constraints: [],
+ additional: 'property',
+ },
+ ];
+
+ validObjects.forEach((obj) =>
+ expect(
+ validateSchema('#/components/schemas/upsertSegmentSchema', obj),
+ ).toBeUndefined(),
+ );
+
+ const invalidObjects = [
+ {
+ name: 'segment',
+ },
+ {
+ description: 'description',
+ constraints: [],
+ },
+ {},
+ ];
+
+ invalidObjects.forEach((obj) =>
+ expect(
+ validateSchema('#/components/schemas/upsertSegmentSchema', obj),
+ ).toMatchSnapshot(),
+ );
+});
diff --git a/src/lib/openapi/spec/upsert-segment-schema.ts b/src/lib/openapi/spec/upsert-segment-schema.ts
index 1377d29814..af84deb363 100644
--- a/src/lib/openapi/spec/upsert-segment-schema.ts
+++ b/src/lib/openapi/spec/upsert-segment-schema.ts
@@ -4,46 +4,34 @@ import { constraintSchema } from './constraint-schema';
export const upsertSegmentSchema = {
$id: '#/components/schemas/upsertSegmentSchema',
type: 'object',
- description:
- 'Represents a segment of users defined by a set of constraints.',
+ description: 'Data used to create or update a segment',
required: ['name', 'constraints'],
properties: {
name: {
+ description: 'The name of the segment',
+ example: 'beta-users',
type: 'string',
- description: 'The name of the segment.',
},
description: {
type: 'string',
nullable: true,
- description: 'The description of the segment.',
+ description: 'A description of what the segment is for',
+ example: 'Users willing to help us test and build new features.',
},
project: {
type: 'string',
nullable: true,
- description:
- 'Project from where this segment will be accessible. If none is defined the segment will be global (i.e. accessible from any project).',
+ description: 'The project the segment belongs to if any.',
+ example: 'red-vista',
},
constraints: {
type: 'array',
- description:
- 'List of constraints that determine which users will be part of the segment',
+ description: 'The list of constraints that make up this segment',
items: {
$ref: '#/components/schemas/constraintSchema',
},
},
},
- example: {
- name: 'segment name',
- description: 'segment description',
- project: 'optional project id',
- constraints: [
- {
- contextName: 'environment',
- operator: 'IN',
- values: ['production', 'staging'],
- },
- ],
- },
components: {
schemas: {
constraintSchema,
diff --git a/src/lib/routes/admin-api/index.ts b/src/lib/routes/admin-api/index.ts
index ef8f94937a..cfba7ddbf5 100644
--- a/src/lib/routes/admin-api/index.ts
+++ b/src/lib/routes/admin-api/index.ts
@@ -32,6 +32,7 @@ import MaintenanceController from './maintenance';
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';
class AdminApi extends Controller {
constructor(config: IUnleashConfig, services: IUnleashServices, db: Db) {
@@ -133,7 +134,10 @@ class AdminApi extends Controller {
`/projects`,
new FavoritesController(config, services).router,
);
-
+ this.app.use(
+ `/segments`,
+ new SegmentsController(config, services).router,
+ );
this.app.use(
'/maintenance',
new MaintenanceController(config, services).router,
diff --git a/src/test/e2e/api/admin/segment.e2e.test.ts b/src/test/e2e/api/admin/segment.e2e.test.ts
new file mode 100644
index 0000000000..a27b6ec5fa
--- /dev/null
+++ b/src/test/e2e/api/admin/segment.e2e.test.ts
@@ -0,0 +1,424 @@
+import { randomId } from '../../../../lib/util/random-id';
+import {
+ IFeatureStrategy,
+ IFeatureToggleClient,
+ ISegment,
+} from '../../../../lib/types/model';
+import { collectIds } from '../../../../lib/util/collect-ids';
+import dbInit, { ITestDb } from '../../helpers/database-init';
+import getLogger from '../../../fixtures/no-logger';
+import {
+ addStrategyToFeatureEnv,
+ createFeatureToggle,
+} from '../../helpers/app.utils';
+import {
+ IUnleashTest,
+ setupAppWithCustomConfig,
+} from '../../helpers/test-helper';
+
+let app: IUnleashTest;
+let db: ITestDb;
+
+const SEGMENTS_BASE_PATH = '/api/admin/segments';
+const FEATURES_LIST_BASE_PATH = '/api/admin/features';
+
+// Recursively change all Date properties to string properties.
+type SerializeDatesDeep = {
+ [P in keyof T]: T[P] extends Date ? string : SerializeDatesDeep;
+};
+
+const fetchSegments = (): Promise> =>
+ app.request
+ .get(SEGMENTS_BASE_PATH)
+ .expect(200)
+ .then((res) => res.body.segments);
+
+const fetchSegmentsByStrategy = (
+ strategyId: string,
+): Promise> =>
+ app.request
+ .get(`${SEGMENTS_BASE_PATH}/strategies/${strategyId}`)
+ .expect(200)
+ .then((res) => res.body.segments);
+
+const fetchFeatures = (): Promise =>
+ app.request
+ .get(FEATURES_LIST_BASE_PATH)
+ .expect(200)
+ .then((res) => res.body.features);
+
+const fetchSegmentStrategies = (
+ segmentId: number,
+): Promise =>
+ app.request
+ .get(`${SEGMENTS_BASE_PATH}/${segmentId}/strategies`)
+ .expect(200)
+ .then((res) => res.body.strategies);
+
+const createSegment = (
+ postData: object,
+ expectStatusCode = 201,
+): Promise =>
+ app.request
+ .post(SEGMENTS_BASE_PATH)
+ .send(postData)
+ .expect(expectStatusCode);
+
+const updateSegment = (
+ id: number,
+ postData: object,
+ expectStatusCode = 204,
+): Promise =>
+ app.request
+ .put(`${SEGMENTS_BASE_PATH}/${id}`)
+ .send(postData)
+ .expect(expectStatusCode);
+
+const addSegmentsToStrategy = (
+ segmentIds: number[],
+ strategyId: string,
+ expectStatusCode = 201,
+): Promise =>
+ app.request
+ .post(`${SEGMENTS_BASE_PATH}/strategies`)
+ .set('Content-type', 'application/json')
+ .send({
+ strategyId,
+ segmentIds,
+ projectId: 'default',
+ environmentId: 'default',
+ additional: 'property',
+ })
+ .expect(expectStatusCode);
+
+const mockFeatureToggle = () => ({
+ name: randomId(),
+ strategies: [{ name: 'flexibleRollout', constraints: [], parameters: {} }],
+});
+
+const validateSegment = (
+ postData: object,
+ expectStatusCode = 204,
+): Promise =>
+ app.request
+ .post(`${SEGMENTS_BASE_PATH}/validate`)
+ .set('Content-type', 'application/json')
+ .send(postData)
+ .expect(expectStatusCode);
+
+beforeAll(async () => {
+ db = await dbInit('segments_api_serial', getLogger);
+ app = await setupAppWithCustomConfig(db.stores, {
+ experimental: {
+ flags: {
+ strictSchemaValidation: true,
+ anonymiseEventLog: true,
+ },
+ },
+ });
+});
+
+afterAll(async () => {
+ await app.destroy();
+ await db.destroy();
+});
+
+afterEach(async () => {
+ await db.stores.segmentStore.deleteAll();
+ await db.stores.featureToggleStore.deleteAll();
+});
+
+test('should validate segments', async () => {
+ await createSegment({ something: 'a' }, 400);
+ await createSegment({ name: randomId(), something: 'b' }, 400);
+ await createSegment({ name: randomId(), constraints: 'b' }, 400);
+ await createSegment({ constraints: [] }, 400);
+ await createSegment({ name: randomId(), constraints: [{}] }, 400);
+ await createSegment({ name: randomId(), constraints: [] });
+ await createSegment({ name: randomId(), description: '', constraints: [] });
+});
+
+test('should fail on missing properties', async () => {
+ const res = await app.request
+ .post(`${SEGMENTS_BASE_PATH}/strategies`)
+ .set('Content-type', 'application/json')
+ .send({
+ projectId: 'default',
+ environmentId: 'default',
+ additional: 'property',
+ });
+
+ expect(res.status).toBe(400);
+});
+
+test('should create segments', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ await createSegment({ name: 'c', constraints: [] });
+ await createSegment({ name: 'b', constraints: [] });
+
+ const segments = await fetchSegments();
+ expect(segments.map((s) => s.name)).toEqual(['a', 'b', 'c']);
+});
+
+test('should update segments', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ const [segmentA] = await fetchSegments();
+ expect(segmentA.id).toBeGreaterThan(0);
+ expect(segmentA.name).toEqual('a');
+ expect(segmentA.createdAt).toBeDefined();
+ expect(segmentA.constraints.length).toEqual(0);
+
+ await updateSegment(segmentA.id, { ...segmentA, name: 'b' });
+
+ const [segmentB] = await fetchSegments();
+ expect(segmentB.id).toEqual(segmentA.id);
+ expect(segmentB.name).toEqual('b');
+ expect(segmentB.createdAt).toBeDefined();
+ expect(segmentB.constraints.length).toEqual(0);
+});
+
+test('should update segment constraints', async () => {
+ const constraintA = { contextName: 'a', operator: 'IN', values: ['x'] };
+ const constraintB = { contextName: 'b', operator: 'IN', values: ['y'] };
+ await createSegment({ name: 'a', constraints: [constraintA] });
+ const [segmentA] = await fetchSegments();
+ expect(segmentA.constraints).toEqual([constraintA]);
+
+ await app.request
+ .put(`${SEGMENTS_BASE_PATH}/${segmentA.id}`)
+ .send({ ...segmentA, constraints: [constraintB, constraintA] })
+ .expect(204);
+
+ const [segmentB] = await fetchSegments();
+ expect(segmentB.constraints).toEqual([constraintB, constraintA]);
+});
+
+test('should delete segments', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ const segments = await fetchSegments();
+ expect(segments.length).toEqual(1);
+
+ await app.request
+ .delete(`${SEGMENTS_BASE_PATH}/${segments[0].id}`)
+ .expect(204);
+
+ expect((await fetchSegments()).length).toEqual(0);
+});
+
+test('should not delete segments used by strategies', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ const toggle = mockFeatureToggle();
+ await createFeatureToggle(app, toggle);
+ const [segment] = await fetchSegments();
+
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle.strategies[0] },
+ 'default',
+ toggle.name,
+ );
+ const [feature] = await fetchFeatures();
+ //@ts-ignore
+ await addSegmentsToStrategy([segment.id], feature.strategies[0].id);
+ const segments = await fetchSegments();
+ expect(segments.length).toEqual(1);
+
+ await app.request
+ .delete(`${SEGMENTS_BASE_PATH}/${segments[0].id}`)
+ .expect(409);
+
+ expect((await fetchSegments()).length).toEqual(1);
+});
+
+test('should list strategies by segment', async () => {
+ await createSegment({ name: 'S1', constraints: [] });
+ await createSegment({ name: 'S2', constraints: [] });
+ await createSegment({ name: 'S3', constraints: [] });
+ const toggle1 = mockFeatureToggle();
+ const toggle2 = mockFeatureToggle();
+ const toggle3 = mockFeatureToggle();
+ await createFeatureToggle(app, toggle1);
+ await createFeatureToggle(app, toggle2);
+ await createFeatureToggle(app, toggle3);
+
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle1.strategies[0] },
+ 'default',
+ toggle1.name,
+ );
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle1.strategies[0] },
+ 'default',
+ toggle2.name,
+ );
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle3.strategies[0] },
+ 'default',
+ toggle3.name,
+ );
+
+ const [feature1, feature2, feature3] = await fetchFeatures();
+ const [segment1, segment2, segment3] = await fetchSegments();
+
+ await addSegmentsToStrategy(
+ [segment1.id, segment2.id, segment3.id],
+ //@ts-ignore
+ feature1.strategies[0].id,
+ );
+ await addSegmentsToStrategy(
+ [segment2.id, segment3.id],
+ //@ts-ignore
+ feature2.strategies[0].id,
+ );
+ //@ts-ignore
+ await addSegmentsToStrategy([segment3.id], feature3.strategies[0].id);
+
+ const segmentStrategies1 = await fetchSegmentStrategies(segment1.id);
+ const segmentStrategies2 = await fetchSegmentStrategies(segment2.id);
+ const segmentStrategies3 = await fetchSegmentStrategies(segment3.id);
+
+ expect(collectIds(segmentStrategies1)).toEqual(
+ collectIds(feature1.strategies),
+ );
+
+ expect(collectIds(segmentStrategies2)).toEqual(
+ collectIds([...feature1.strategies, ...feature2.strategies]),
+ );
+
+ expect(collectIds(segmentStrategies3)).toEqual(
+ collectIds([
+ ...feature1.strategies,
+ ...feature2.strategies,
+ ...feature3.strategies,
+ ]),
+ );
+});
+
+test('should list segments by strategy', async () => {
+ await createSegment({ name: 'S1', constraints: [] });
+ await createSegment({ name: 'S2', constraints: [] });
+ await createSegment({ name: 'S3', constraints: [] });
+ const toggle1 = mockFeatureToggle();
+ const toggle2 = mockFeatureToggle();
+ const toggle3 = mockFeatureToggle();
+ await createFeatureToggle(app, toggle1);
+ await createFeatureToggle(app, toggle2);
+ await createFeatureToggle(app, toggle3);
+
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle1.strategies[0] },
+ 'default',
+ toggle1.name,
+ );
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle1.strategies[0] },
+ 'default',
+ toggle2.name,
+ );
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle3.strategies[0] },
+ 'default',
+ toggle3.name,
+ );
+
+ const [feature1, feature2, feature3] = await fetchFeatures();
+ const [segment1, segment2, segment3] = await fetchSegments();
+
+ await addSegmentsToStrategy(
+ [segment1.id, segment2.id, segment3.id],
+ //@ts-ignore
+ feature1.strategies[0].id,
+ );
+ await addSegmentsToStrategy(
+ [segment2.id, segment3.id],
+ //@ts-ignore
+ feature2.strategies[0].id,
+ );
+ //@ts-ignore
+ await addSegmentsToStrategy([segment3.id], feature3.strategies[0].id);
+
+ const strategySegments1 = await fetchSegmentsByStrategy(
+ //@ts-ignore
+ feature1.strategies[0].id,
+ );
+ const strategySegments2 = await fetchSegmentsByStrategy(
+ //@ts-ignore
+ feature2.strategies[0].id,
+ );
+ const strategySegments3 = await fetchSegmentsByStrategy(
+ //@ts-ignore
+ feature3.strategies[0].id,
+ );
+
+ expect(collectIds(strategySegments1)).toEqual(
+ collectIds([segment1, segment2, segment3]),
+ );
+
+ expect(collectIds(strategySegments2)).toEqual(
+ collectIds([segment2, segment3]),
+ );
+
+ expect(collectIds(strategySegments3)).toEqual(collectIds([segment3]));
+});
+
+test('should reject duplicate segment names', async () => {
+ await validateSegment({ name: 'a' });
+ await createSegment({ name: 'a', constraints: [] });
+ await validateSegment({ name: 'a' }, 409);
+ await validateSegment({ name: 'b' });
+});
+
+test('should reject empty segment names', async () => {
+ await validateSegment({ name: 'a' });
+ await validateSegment({}, 400);
+ await validateSegment({ name: '' }, 400);
+});
+
+test('should reject duplicate segment names on create', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ await createSegment({ name: 'a', constraints: [] }, 409);
+ await validateSegment({ name: 'b' });
+});
+
+test('should reject duplicate segment names on update', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ await createSegment({ name: 'b', constraints: [] });
+ const [segmentA, segmentB] = await fetchSegments();
+ await updateSegment(segmentA.id, { name: 'b', constraints: [] }, 409);
+ await updateSegment(segmentB.id, { name: 'a', constraints: [] }, 409);
+ await updateSegment(segmentA.id, { name: 'a', constraints: [] });
+ await updateSegment(segmentA.id, { name: 'c', constraints: [] });
+});
+
+test('Should anonymise createdBy field if anonymiseEventLog flag is set', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ await createSegment({ name: 'b', constraints: [] });
+ const segments = await fetchSegments();
+ expect(segments).toHaveLength(2);
+ expect(segments[0].createdBy).toContain('unleash.run');
+});
+
+test('Should show usage in features and projects', async () => {
+ await createSegment({ name: 'a', constraints: [] });
+ const toggle = mockFeatureToggle();
+ await createFeatureToggle(app, toggle);
+ const [segment] = await fetchSegments();
+ await addStrategyToFeatureEnv(
+ app,
+ { ...toggle.strategies[0] },
+ 'default',
+ toggle.name,
+ );
+ const [feature] = await fetchFeatures();
+ //@ts-ignore
+ await addSegmentsToStrategy([segment.id], feature.strategies[0].id);
+
+ const segments = await fetchSegments();
+ expect(segments).toMatchObject([{ usedInFeatures: 1, usedInProjects: 1 }]);
+});
diff --git a/src/test/e2e/helpers/app.utils.ts b/src/test/e2e/helpers/app.utils.ts
new file mode 100644
index 0000000000..9d271e46c3
--- /dev/null
+++ b/src/test/e2e/helpers/app.utils.ts
@@ -0,0 +1,26 @@
+import { CreateFeatureStrategySchema } from 'lib/openapi';
+import { IUnleashTest } from './test-helper';
+
+export const FEATURES_BASE_PATH = '/api/admin/projects/default/features';
+export const ADMIN_BASE_PATH = '/api/admin';
+
+export const createFeatureToggle = (
+ app: IUnleashTest,
+ postData: object,
+ expectStatusCode = 201,
+): Promise =>
+ app.request
+ .post(FEATURES_BASE_PATH)
+ .send(postData)
+ .expect(expectStatusCode);
+
+export const addStrategyToFeatureEnv = (
+ app: IUnleashTest,
+ postData: CreateFeatureStrategySchema,
+ envName: string,
+ featureName: string,
+ expectStatusCode = 200,
+): Promise => {
+ const url = `${ADMIN_BASE_PATH}/projects/default/features/${featureName}/environments/${envName}/strategies`;
+ return app.request.post(url).send(postData).expect(expectStatusCode);
+};
diff --git a/website/docs/reference/segments.mdx b/website/docs/reference/segments.mdx
index 2b5a38052b..62203a8e46 100644
--- a/website/docs/reference/segments.mdx
+++ b/website/docs/reference/segments.mdx
@@ -6,7 +6,8 @@ import VideoContent from '@site/src/components/VideoContent.jsx'
:::info Availability
-Segments are available to Unleash Pro and Unleash Enterprise users since **Unleash 4.13**.
+Segments are available to Unleash Pro and Unleash Enterprise users since **Unleash 4.13** and
+was made available for Open Source from Unleash v5.5.
:::