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,
}
}
}
```
This commit is contained in:
7
.changeset/loud-wombats-shave.md
Normal file
7
.changeset/loud-wombats-shave.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@medusajs/medusa": patch
|
||||
"@medusajs/types": patch
|
||||
"@medusajs/utils": patch
|
||||
---
|
||||
|
||||
feat(medusa, utils): Allow object feature flags
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, string | boolean> } = {},
|
||||
configModule: {
|
||||
featureFlags?: Record<string, string | boolean | Record<string, boolean>>
|
||||
} = {},
|
||||
logger?: Logger,
|
||||
flagDirectory?: string
|
||||
): FlagRouter => {
|
||||
@@ -28,7 +34,7 @@ export default (
|
||||
ignore: ["**/index.js", "**/index.ts", "**/*.d.ts"],
|
||||
})
|
||||
|
||||
const flagConfig: Record<string, boolean> = {}
|
||||
const flagConfig: Record<string, boolean | Record<string, boolean>> = {}
|
||||
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<string, boolean>
|
||||
}
|
||||
}
|
||||
|
||||
if (logger && from) {
|
||||
|
||||
10
packages/medusa/src/loaders/feature-flags/workflows.ts
Normal file
10
packages/medusa/src/loaders/feature-flags/workflows.ts
Normal file
@@ -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
|
||||
@@ -21,7 +21,7 @@ export interface IFlagRouter {
|
||||
*/
|
||||
export type FeatureFlagsResponse = {
|
||||
key: string
|
||||
value: boolean
|
||||
value: boolean | Record<string, boolean>
|
||||
}[]
|
||||
|
||||
export type FlagSettings = {
|
||||
|
||||
@@ -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<string, boolean> = {}
|
||||
export class FlagRouter implements FeatureFlagTypes.IFlagRouter {
|
||||
private readonly flags: Record<string, boolean | Record<string, boolean>> = {}
|
||||
|
||||
constructor(flags: Record<string, boolean>) {
|
||||
constructor(flags: Record<string, boolean | Record<string, boolean>>) {
|
||||
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<string, string>): 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,
|
||||
|
||||
@@ -21,7 +21,7 @@ export interface IFlagRouter {
|
||||
*/
|
||||
export type FeatureFlagsResponse = {
|
||||
key: string
|
||||
value: boolean
|
||||
value: boolean | Record<string, boolean>
|
||||
}[]
|
||||
|
||||
export type FlagSettings = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
75
packages/utils/src/common/object-from-string-path.ts
Normal file
75
packages/utils/src/common/object-from-string-path.ts
Normal file
@@ -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<string, any> {
|
||||
collection = collection.sort()
|
||||
const output: Record<string, any> = {}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,17 +1,68 @@
|
||||
import { FeatureFlagTypes } from "@medusajs/types"
|
||||
import { isObject, isString } from "../../common"
|
||||
|
||||
export class FlagRouter implements FeatureFlagTypes.IFlagRouter {
|
||||
private readonly flags: Record<string, boolean> = {}
|
||||
private readonly flags: Record<string, boolean | Record<string, boolean>> = {}
|
||||
|
||||
constructor(flags: Record<string, boolean>) {
|
||||
constructor(flags: Record<string, boolean | Record<string, boolean>>) {
|
||||
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<string, string>): 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
|
||||
}
|
||||
|
||||
|
||||
8
packages/utils/src/feature-flags/workflows.ts
Normal file
8
packages/utils/src/feature-flags/workflows.ts
Normal file
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user