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:
Oli Juhl authored and GitHub committed 2023-08-07 09:38:25 +00:00
1 parent 03fb0479c0
commit 5c60aad177
17 files changed
+478 -115

No files matched your search

@@ -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) {
@@ -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
+1 -1
View File
@@ -21,7 +21,7 @@ export interface IFlagRouter {
*/
export type FeatureFlagsResponse = {
key: string
value: boolean
value: boolean | Record<string, boolean>
}[]
export type FlagSettings = {
+59 -8
View File
@@ -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,