From 5c60aad177a99574ffff5ebdc02ce9dc86ef9af9 Mon Sep 17 00:00:00 2001 From: Oli Juhl <59018053+olivermrbl@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:38:25 +0200 Subject: [PATCH] feat(medusa, utils): Allow object feature flags (#4701) Feature flags can be set as follows: **Environment variables** ``` MEDUSA_FF_ANALYTICS=true MEDUSA_FF_WORKFLOWS=createProducts,addShippingMethods ``` **Project config** ``` { featureFlags: { analytics: true, workflows: { createProducts: true, addShippingMethods: true, } } } ``` --- .changeset/loud-wombats-shave.md | 7 + .../plugins/__tests__/product/admin/index.ts | 15 +- integration-tests/plugins/medusa-config.js | 6 + .../routes/admin/products/create-product.ts | 36 ++-- .../loaders/__tests__/feature-flags.spec.ts | 23 ++- .../medusa/src/loaders/feature-flags/index.ts | 37 ++++- .../src/loaders/feature-flags/workflows.ts | 10 ++ packages/medusa/src/types/feature-flags.ts | 2 +- packages/medusa/src/utils/flag-router.ts | 67 +++++++- packages/types/src/feature-flag/common.ts | 2 +- packages/utils/src/common/build-query.ts | 76 +-------- packages/utils/src/common/index.ts | 10 +- .../src/common/object-from-string-path.ts | 75 +++++++++ packages/utils/src/feature-flags/index.ts | 4 +- .../utils/__tests__/flag-router.spec.ts | 154 ++++++++++++++++++ .../src/feature-flags/utils/flag-router.ts | 61 ++++++- packages/utils/src/feature-flags/workflows.ts | 8 + 17 files changed, 478 insertions(+), 115 deletions(-) create mode 100644 .changeset/loud-wombats-shave.md create mode 100644 packages/medusa/src/loaders/feature-flags/workflows.ts create mode 100644 packages/utils/src/common/object-from-string-path.ts create mode 100644 packages/utils/src/feature-flags/utils/__tests__/flag-router.spec.ts create mode 100644 packages/utils/src/feature-flags/workflows.ts diff --git a/.changeset/loud-wombats-shave.md b/.changeset/loud-wombats-shave.md new file mode 100644 index 0000000000..5f2cf23314 --- /dev/null +++ b/.changeset/loud-wombats-shave.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa": patch +"@medusajs/types": patch +"@medusajs/utils": patch +--- + +feat(medusa, utils): Allow object feature flags diff --git a/integration-tests/plugins/__tests__/product/admin/index.ts b/integration-tests/plugins/__tests__/product/admin/index.ts index 826c55e107..86d61547aa 100644 --- a/integration-tests/plugins/__tests__/product/admin/index.ts +++ b/integration-tests/plugins/__tests__/product/admin/index.ts @@ -6,9 +6,10 @@ import { initDb, useDb } from "../../../../environment-helpers/use-db" import adminSeeder from "../../../../helpers/admin-seeder" import productSeeder from "../../../../helpers/product-seeder" -import { simpleSalesChannelFactory } from "../../../../factories" -import { AxiosInstance } from "axios" import { Modules, ModulesDefinition } from "@medusajs/modules-sdk" +import { Workflows } from "@medusajs/workflows" +import { AxiosInstance } from "axios" +import { simpleSalesChannelFactory } from "../../../../factories" jest.setTimeout(5000000) @@ -51,6 +52,16 @@ describe("/admin/products", () => { ).toBeTruthy() }) + it("Should have enabled workflows feature flag", function () { + const flagRouter = medusaContainer.resolve("featureFlagRouter") + + const workflowsFlag = flagRouter.isFeatureEnabled({ + workflows: Workflows.CreateProducts, + }) + + expect(workflowsFlag).toBe(true) + }) + describe("POST /admin/products", () => { beforeEach(async () => { await productSeeder(dbConnection) diff --git a/integration-tests/plugins/medusa-config.js b/integration-tests/plugins/medusa-config.js index bd0b5b268b..1ea26e8f81 100644 --- a/integration-tests/plugins/medusa-config.js +++ b/integration-tests/plugins/medusa-config.js @@ -1,4 +1,5 @@ const { Modules } = require("@medusajs/modules-sdk") +const { Workflows } = require("@medusajs/workflows") const DB_HOST = process.env.DB_HOST const DB_USERNAME = process.env.DB_USERNAME const DB_PASSWORD = process.env.DB_PASSWORD @@ -31,6 +32,11 @@ module.exports = { cookie_secret: "test", database_extra: { idle_in_transaction_session_timeout: 0 }, }, + featureFlags: { + workflows: { + [Workflows.CreateProducts]: true, + }, + }, modules: { [Modules.STOCK_LOCATION]: { scope: "internal", diff --git a/packages/medusa/src/api/routes/admin/products/create-product.ts b/packages/medusa/src/api/routes/admin/products/create-product.ts index 693b57c769..1607f6b0cd 100644 --- a/packages/medusa/src/api/routes/admin/products/create-product.ts +++ b/packages/medusa/src/api/routes/admin/products/create-product.ts @@ -1,7 +1,3 @@ -import { - CreateProductVariantInput, - ProductVariantPricesCreateReq, -} from "../../../../types/product-variant" import { IsArray, IsBoolean, @@ -12,6 +8,7 @@ import { IsString, ValidateNested, } from "class-validator" +import { defaultAdminProductFields, defaultAdminProductRelations } from "." import { PricingService, ProductService, @@ -26,23 +23,26 @@ import { ProductTagReq, ProductTypeReq, } from "../../../../types/product" +import { + CreateProductVariantInput, + ProductVariantPricesCreateReq, +} from "../../../../types/product-variant" import { createVariantsTransaction, revertVariantTransaction, } from "./transaction/create-product-variant" -import { defaultAdminProductFields, defaultAdminProductRelations } from "." import { DistributedTransaction } from "@medusajs/orchestration" -import { EntityManager } from "typeorm" -import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" -import { FlagRouter } from "../../../../utils/flag-router" import { IInventoryService, WorkflowTypes } from "@medusajs/types" -import { Logger } from "../../../../types/global" -import { ProductStatus } from "../../../../models" -import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" +import { FlagRouter } from "@medusajs/utils" +import { Workflows, createProducts } from "@medusajs/workflows" import { Type } from "class-transformer" +import { EntityManager } from "typeorm" +import SalesChannelFeatureFlag from "../../../../loaders/feature-flags/sales-channels" +import { ProductStatus } from "../../../../models" +import { Logger } from "../../../../types/global" import { validator } from "../../../../utils" -import { createProducts } from "@medusajs/workflows" +import { FeatureFlagDecorators } from "../../../../utils/feature-flag-decorators" /** * @oas [post] /admin/products @@ -132,7 +132,17 @@ export default async (req, res) => { const entityManager: EntityManager = req.scope.resolve("manager") const productModuleService = req.scope.resolve("productModuleService") - if (productModuleService) { + const isWorkflowEnabled = featureFlagRouter.isFeatureEnabled({ + workflows: Workflows.CreateProducts, + }) + + if (isWorkflowEnabled && !productModuleService) { + logger.warn( + `Cannot run ${Workflows.CreateProducts} workflow without '@medusajs/product' installed` + ) + } + + if (isWorkflowEnabled && !!productModuleService) { const createProductWorkflow = createProducts(req.scope) const input = { diff --git a/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts b/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts index 32adee8b6e..500f571b80 100644 --- a/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts +++ b/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts @@ -1,5 +1,5 @@ -import { resolve } from "path" import { mkdirSync, rmSync, writeFileSync } from "fs" +import { resolve } from "path" import loadFeatureFlags from "../feature-flags" @@ -67,6 +67,27 @@ describe("feature flags", () => { expect(flags.isFeatureEnabled("flag_1")).toEqual(false) }) + it("should load a nested + simple flag from project", async () => { + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("flags"), "test.js"), + buildFeatureFlag("test", false) + ) + + writeFileSync( + resolve(getFolderTestTargetDirectoryPath("flags"), "simpletest.js"), + buildFeatureFlag("simpletest", false) + ) + + const flags = await loadFeatureFlags( + { featureFlags: { test: { nested: true }, simpletest: true } }, + undefined, + getFolderTestTargetDirectoryPath("flags") + ) + + expect(flags.isFeatureEnabled({ test: "nested" })).toEqual(true) + expect(flags.isFeatureEnabled("simpletest")).toEqual(true) + }) + it("should load the default feature flags", async () => { writeFileSync( resolve(getFolderTestTargetDirectoryPath("flags"), "flag-1.js"), diff --git a/packages/medusa/src/loaders/feature-flags/index.ts b/packages/medusa/src/loaders/feature-flags/index.ts index 6609bba3c8..6ea4bbd954 100644 --- a/packages/medusa/src/loaders/feature-flags/index.ts +++ b/packages/medusa/src/loaders/feature-flags/index.ts @@ -1,11 +1,15 @@ +import { + FlagRouter, + isObject, + isString, + objectFromStringPath, +} from "@medusajs/utils" import glob from "glob" -import path from "path" - import { isDefined } from "medusa-core-utils" import { trackFeatureFlag } from "medusa-telemetry" +import path from "path" import { FlagSettings } from "../../types/feature-flags" import { Logger } from "../../types/global" -import { FlagRouter } from "../../utils/flag-router" const isTruthy = (val: string | boolean | undefined): boolean => { if (typeof val === "string") { @@ -17,7 +21,9 @@ const isTruthy = (val: string | boolean | undefined): boolean => { export const featureFlagRouter = new FlagRouter({}) export default ( - configModule: { featureFlags?: Record } = {}, + configModule: { + featureFlags?: Record> + } = {}, logger?: Logger, flagDirectory?: string ): FlagRouter => { @@ -28,7 +34,7 @@ export default ( ignore: ["**/index.js", "**/index.ts", "**/*.d.ts"], }) - const flagConfig: Record = {} + const flagConfig: Record> = {} for (const flag of supportedFlags) { const flagSettings: FlagSettings = require(flag).default if (!flagSettings) { @@ -40,12 +46,31 @@ export default ( let from if (isDefined(process.env[flagSettings.env_key])) { from = "environment" + const envVal = process.env[flagSettings.env_key] + + // MEDUSA_FF_ANALYTICS="true" flagConfig[flagSettings.key] = isTruthy(process.env[flagSettings.env_key]) + + const parsedFromEnv = isString(envVal) ? envVal.split(",") : [] + + // MEDUSA_FF_WORKFLOWS=createProducts,deleteProducts + if (parsedFromEnv.length > 1) { + flagConfig[flagSettings.key] = objectFromStringPath(parsedFromEnv) + } } else if (isDefined(projectConfigFlags[flagSettings.key])) { from = "project config" + + // featureFlags: { analytics: "true" | true } flagConfig[flagSettings.key] = isTruthy( - projectConfigFlags[flagSettings.key] + projectConfigFlags[flagSettings.key] as string | boolean ) + + // featureFlags: { workflows: { createProducts: true } } + if (isObject(projectConfigFlags[flagSettings.key])) { + flagConfig[flagSettings.key] = projectConfigFlags[ + flagSettings.key + ] as Record + } } if (logger && from) { diff --git a/packages/medusa/src/loaders/feature-flags/workflows.ts b/packages/medusa/src/loaders/feature-flags/workflows.ts new file mode 100644 index 0000000000..a49cc918e7 --- /dev/null +++ b/packages/medusa/src/loaders/feature-flags/workflows.ts @@ -0,0 +1,10 @@ +import { FeatureFlagTypes } from "@medusajs/types" + +const WorkflowsFeatureFlag: FeatureFlagTypes.FlagSettings = { + key: "workflows", + default_val: false, + env_key: "MEDUSA_FF_WORKFLOWS", + description: "[WIP] Enable workflows", +} + +export default WorkflowsFeatureFlag diff --git a/packages/medusa/src/types/feature-flags.ts b/packages/medusa/src/types/feature-flags.ts index 074acba22a..0131f3b7ba 100644 --- a/packages/medusa/src/types/feature-flags.ts +++ b/packages/medusa/src/types/feature-flags.ts @@ -21,7 +21,7 @@ export interface IFlagRouter { */ export type FeatureFlagsResponse = { key: string - value: boolean + value: boolean | Record }[] export type FlagSettings = { diff --git a/packages/medusa/src/utils/flag-router.ts b/packages/medusa/src/utils/flag-router.ts index 974377221a..77e019165e 100644 --- a/packages/medusa/src/utils/flag-router.ts +++ b/packages/medusa/src/utils/flag-router.ts @@ -1,21 +1,72 @@ -import { FeatureFlagsResponse, IFlagRouter } from "../types/feature-flags" +import { FeatureFlagTypes } from "@medusajs/types" +import { isObject, isString } from "@medusajs/utils" -export class FlagRouter implements IFlagRouter { - private readonly flags: Record = {} +export class FlagRouter implements FeatureFlagTypes.IFlagRouter { + private readonly flags: Record> = {} - constructor(flags: Record) { + constructor(flags: Record>) { this.flags = flags } - public isFeatureEnabled(key: string): boolean { - return !!this.flags[key] + /** + * Check if a feature flag is enabled. + * There are two ways of using this method: + * 1. `isFeatureEnabled("myFeatureFlag")` + * 2. `isFeatureEnabled({ myNestedFeatureFlag: "someNestedFlag" })` + * We use 1. for top-level feature flags and 2. for nested feature flags. Almost all flags are top-level. + * An example of a nested flag is workflows. To use it, you would do: + * `isFeatureEnabled({ workflows: Workflows.CreateCart })` + * @param flag - The flag to check + * @return {boolean} - Whether the flag is enabled or not + */ + public isFeatureEnabled(flag: string | Record): boolean { + if (isString(flag)) { + return !!this.flags[flag] + } + + if (isObject(flag)) { + const [nestedFlag, value] = Object.entries(flag)[0] + + if (typeof this.flags[nestedFlag] === "boolean") { + return this.flags[nestedFlag] as boolean + } + + return !!this.flags[nestedFlag]?.[value] + } + + throw Error("Flag must be a string or an object") } - public setFlag(key: string, value = true): void { + /** + * Sets a feature flag. + * Flags take two shapes: + * setFlag("myFeatureFlag", true) + * setFlag("myFeatureFlag", { nestedFlag: true }) + * These shapes are used for top-level and nested flags respectively, as explained in isFeatureEnabled. + * @param key - The key of the flag to set. + * @param value - The value of the flag to set. + * @return {void} - void + */ + public setFlag( + key: string, + value: boolean | { [key: string]: boolean } + ): void { + if (isObject(value)) { + const existing = this.flags[key] + + if (!existing) { + this.flags[key] = value + return + } + + this.flags[key] = { ...(this.flags[key] as object), ...value } + return + } + this.flags[key] = value } - public listFlags(): FeatureFlagsResponse { + public listFlags(): FeatureFlagTypes.FeatureFlagsResponse { return Object.entries(this.flags || {}).map(([key, value]) => ({ key, value, diff --git a/packages/types/src/feature-flag/common.ts b/packages/types/src/feature-flag/common.ts index 074acba22a..0131f3b7ba 100644 --- a/packages/types/src/feature-flag/common.ts +++ b/packages/types/src/feature-flag/common.ts @@ -21,7 +21,7 @@ export interface IFlagRouter { */ export type FeatureFlagsResponse = { key: string - value: boolean + value: boolean | Record }[] export type FlagSettings = { diff --git a/packages/utils/src/common/build-query.ts b/packages/utils/src/common/build-query.ts index 641c5b63cd..e5d0489db3 100644 --- a/packages/utils/src/common/build-query.ts +++ b/packages/utils/src/common/build-query.ts @@ -1,5 +1,8 @@ // Those utils are used in a typeorm context and we can't be sure that they can be used elsewhere +import { objectFromStringPath } from "./object-from-string-path" + + type Order = { [key: string]: "ASC" | "DESC" | Order } @@ -20,79 +23,8 @@ export function buildRelations(relationCollection: string[]): Relations { return buildRelationsOrSelect(relationCollection) } -/** - * Convert a collection of dot string into a nested object - * @example - * input: [ - * order, - * order.items, - * order.swaps, - * order.swaps.additional_items, - * order.discounts, - * order.discounts.rule, - * order.claims, - * order.claims.additional_items, - * additional_items, - * additional_items.variant, - * return_order, - * return_order.items, - * return_order.shipping_method, - * return_order.shipping_method.tax_lines - * ] - * output: { - * "order": { - * "items": true, - * "swaps": { - * "additional_items": true - * }, - * "discounts": { - * "rule": true - * }, - * "claims": { - * "additional_items": true - * } - * }, - * "additional_items": { - * "variant": true - * }, - * "return_order": { - * "items": true, - * "shipping_method": { - * "tax_lines": true - * } - * } - * } - * @param collection - */ function buildRelationsOrSelect(collection: string[]): Selects | Relations { - collection = collection.sort() - const output: Selects | Relations = {} - - for (const relation of collection) { - if (relation.indexOf(".") > -1) { - const nestedRelations = relation.split(".") - - let parent = output - - while (nestedRelations.length > 1) { - const nestedRelation = nestedRelations.shift() as string - parent = parent[nestedRelation] = ( - parent[nestedRelation] !== true && - typeof parent[nestedRelation] === "object" - ? parent[nestedRelation] - : {} - ) as Selects | Relations - } - - parent[nestedRelations[0]] = true - - continue - } - - output[relation] = output[relation] ?? true - } - - return output + return objectFromStringPath(collection) } /** diff --git a/packages/utils/src/common/index.ts b/packages/utils/src/common/index.ts index e26d48589a..728509715c 100644 --- a/packages/utils/src/common/index.ts +++ b/packages/utils/src/common/index.ts @@ -10,16 +10,16 @@ export * from "./is-email" export * from "./is-object" export * from "./is-string" export * from "./lower-case-first" -export * from "./upper-case-first" +export * from "./map-object-to" export * from "./medusa-container" +export * from "./object-from-string-path" export * from "./object-to-string-path" export * from "./set-metadata" export * from "./simple-hash" -export * from "./wrap-handler" -export * from "./to-kebab-case" -export * from "./to-camel-case" export * from "./stringify-circular" +export * from "./to-camel-case" export * from "./to-kebab-case" export * from "./to-pascal-case" +export * from "./upper-case-first" export * from "./wrap-handler" -export * from "./map-object-to" + diff --git a/packages/utils/src/common/object-from-string-path.ts b/packages/utils/src/common/object-from-string-path.ts new file mode 100644 index 0000000000..77177cffdf --- /dev/null +++ b/packages/utils/src/common/object-from-string-path.ts @@ -0,0 +1,75 @@ +/** + * Convert a collection of dot string into a nested object + * @example + * input: [ + * order, + * order.items, + * order.swaps, + * order.swaps.additional_items, + * order.discounts, + * order.discounts.rule, + * order.claims, + * order.claims.additional_items, + * additional_items, + * additional_items.variant, + * return_order, + * return_order.items, + * return_order.shipping_method, + * return_order.shipping_method.tax_lines + * ] + * output: { + * "order": { + * "items": true, + * "swaps": { + * "additional_items": true + * }, + * "discounts": { + * "rule": true + * }, + * "claims": { + * "additional_items": true + * } + * }, + * "additional_items": { + * "variant": true + * }, + * "return_order": { + * "items": true, + * "shipping_method": { + * "tax_lines": true + * } + * } + * } + * @param collection + */ +export function objectFromStringPath( + collection: string[] +): Record { + collection = collection.sort() + const output: Record = {} + + for (const relation of collection) { + if (relation.indexOf(".") > -1) { + const nestedRelations = relation.split(".") + + let parent = output + + while (nestedRelations.length > 1) { + const nestedRelation = nestedRelations.shift() as string + parent = parent[nestedRelation] = + parent[nestedRelation] !== true && + typeof parent[nestedRelation] === "object" + ? parent[nestedRelation] + : {} + } + + parent[nestedRelations[0]] = true + + continue + } + + output[relation] = output[relation] ?? true + } + + return output +} diff --git a/packages/utils/src/feature-flags/index.ts b/packages/utils/src/feature-flags/index.ts index ac515f7beb..2f795e693a 100644 --- a/packages/utils/src/feature-flags/index.ts +++ b/packages/utils/src/feature-flags/index.ts @@ -1,7 +1,9 @@ export * from "./analytics" export * from "./order-editing" -export * from "./sales-channels" export * from "./product-categories" export * from "./publishable-api-keys" +export * from "./sales-channels" export * from "./tax-inclusive-pricing" export * from "./utils" +export * from "./workflows" + diff --git a/packages/utils/src/feature-flags/utils/__tests__/flag-router.spec.ts b/packages/utils/src/feature-flags/utils/__tests__/flag-router.spec.ts new file mode 100644 index 0000000000..859bd1e2de --- /dev/null +++ b/packages/utils/src/feature-flags/utils/__tests__/flag-router.spec.ts @@ -0,0 +1,154 @@ +import { FlagRouter } from "../flag-router" + +const someFlag = { + key: "some_flag", + default_val: false, + env_key: "MEDUSA_FF_SOME_FLAG", + description: "[WIP] Enable some flag", +} + +const workflows = { + key: "workflows", + default_val: {}, + env_key: "MEDUSA_FF_WORKFLOWS", + description: "[WIP] Enable workflows", +} + +describe("FlagRouter", function () { + it("should set a top-level flag", async function () { + const flagRouter = new FlagRouter({}) + + flagRouter.setFlag(someFlag.key, true) + + expect(flagRouter.listFlags()).toEqual([ + { + key: someFlag.key, + value: true, + }, + ]) + }) + + it("should set a nested flag", async function () { + const flagRouter = new FlagRouter({}) + + flagRouter.setFlag(workflows.key, { createCart: true }) + + expect(flagRouter.listFlags()).toEqual([ + { + key: workflows.key, + value: { + createCart: true, + }, + }, + ]) + }) + + it("should append to a nested flag", async function () { + const flagRouter = new FlagRouter({}) + + flagRouter.setFlag(workflows.key, { createCart: true }) + flagRouter.setFlag(workflows.key, { addShippingMethod: true }) + + expect(flagRouter.listFlags()).toEqual([ + { + key: workflows.key, + value: { + createCart: true, + addShippingMethod: true, + }, + }, + ]) + }) + + it("should check if top-level flag is enabled", async function () { + const flagRouter = new FlagRouter({ + [someFlag.key]: true, + }) + + const isEnabled = flagRouter.isFeatureEnabled(someFlag.key) + + expect(isEnabled).toEqual(true) + }) + + it("should check if nested flag is enabled", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: { + createCart: true, + }, + }) + + const isEnabled = flagRouter.isFeatureEnabled({ workflows: "createCart" }) + + expect(isEnabled).toEqual(true) + }) + + it("should check if nested flag is enabled using top-level access", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: { + createCart: true, + }, + }) + + const isEnabled = flagRouter.isFeatureEnabled(workflows.key) + + expect(isEnabled).toEqual(true) + }) + + it("should return true if top-level is enabled using nested-level access", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: true, + }) + + const isEnabled = flagRouter.isFeatureEnabled({ + [workflows.key]: "createCart", + }) + + expect(isEnabled).toEqual(true) + }) + + it("should return false if flag is disabled using top-level access", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: false, + }) + + const isEnabled = flagRouter.isFeatureEnabled(workflows.key) + + expect(isEnabled).toEqual(false) + }) + + it("should return false if nested flag is disabled", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: { + createCart: false, + }, + }) + + const isEnabled = flagRouter.isFeatureEnabled({ workflows: "createCart" }) + + expect(isEnabled).toEqual(false) + }) + + it("should initialize with both types of flags", async function () { + const flagRouter = new FlagRouter({ + [workflows.key]: { + createCart: true, + }, + [someFlag.key]: true, + }) + + const flags = flagRouter.listFlags() + + expect(flags).toEqual([ + { + key: workflows.key, + value: { + createCart: true, + }, + }, + { + key: someFlag.key, + value: true, + }, + ]) + }) +}) diff --git a/packages/utils/src/feature-flags/utils/flag-router.ts b/packages/utils/src/feature-flags/utils/flag-router.ts index 4ac7dc9277..64bbbdce71 100644 --- a/packages/utils/src/feature-flags/utils/flag-router.ts +++ b/packages/utils/src/feature-flags/utils/flag-router.ts @@ -1,17 +1,68 @@ import { FeatureFlagTypes } from "@medusajs/types" +import { isObject, isString } from "../../common" export class FlagRouter implements FeatureFlagTypes.IFlagRouter { - private readonly flags: Record = {} + private readonly flags: Record> = {} - constructor(flags: Record) { + constructor(flags: Record>) { this.flags = flags } - public isFeatureEnabled(key: string): boolean { - return !!this.flags[key] + /** + * Check if a feature flag is enabled. + * There are two ways of using this method: + * 1. `isFeatureEnabled("myFeatureFlag")` + * 2. `isFeatureEnabled({ myNestedFeatureFlag: "someNestedFlag" })` + * We use 1. for top-level feature flags and 2. for nested feature flags. Almost all flags are top-level. + * An example of a nested flag is workflows. To use it, you would do: + * `isFeatureEnabled({ workflows: Workflows.CreateCart })` + * @param flag - The flag to check + * @return {boolean} - Whether the flag is enabled or not + */ + public isFeatureEnabled(flag: string | Record): boolean { + if (isString(flag)) { + return !!this.flags[flag] + } + + if (isObject(flag)) { + const [nestedFlag, value] = Object.entries(flag)[0] + + if (typeof this.flags[nestedFlag] === "boolean") { + return this.flags[nestedFlag] as boolean + } + + return !!this.flags[nestedFlag]?.[value] + } + + throw Error("Flag must be a string or an object") } - public setFlag(key: string, value = true): void { + /** + * Sets a feature flag. + * Flags take two shapes: + * setFlag("myFeatureFlag", true) + * setFlag("myFeatureFlag", { nestedFlag: true }) + * These shapes are used for top-level and nested flags respectively, as explained in isFeatureEnabled. + * @param key - The key of the flag to set. + * @param value - The value of the flag to set. + * @return {void} - void + */ + public setFlag( + key: string, + value: boolean | { [key: string]: boolean } + ): void { + if (isObject(value)) { + const existing = this.flags[key] + + if (!existing) { + this.flags[key] = value + return + } + + this.flags[key] = { ...(this.flags[key] as object), ...value } + return + } + this.flags[key] = value } diff --git a/packages/utils/src/feature-flags/workflows.ts b/packages/utils/src/feature-flags/workflows.ts new file mode 100644 index 0000000000..6e17448090 --- /dev/null +++ b/packages/utils/src/feature-flags/workflows.ts @@ -0,0 +1,8 @@ +import { FeatureFlagTypes } from "@medusajs/types" + +export const WorkflowsFeatureFlag: FeatureFlagTypes.FlagSettings = { + key: "workflows", + default_val: false, + env_key: "MEDUSA_FF_WORKFLOWS", + description: "[WIP] Enable workflows", +}