chore(framework): Move feature flags related resources and cleanup (#8297)

**What**
cleanup and move the feature flag related resources to manage them.

It also include some refactoring around loading and registering the flag as well as not relying on the `glob` package anymore plus some reorganization of the code itself

FIXES FRMW-2625
This commit is contained in:
Adrien de Peretti
2024-07-30 12:20:03 +00:00
committed by GitHub
parent 8a5751c115
commit 9b6de8c02d
21 changed files with 364 additions and 174 deletions
@@ -32,6 +32,12 @@
"import": "./dist/database/index.js",
"require": "./dist/database/index.js",
"node": "./dist/database/index.js"
},
"./feature-flag": {
"types": "./dist/feature-flags/index.d.ts",
"import": "./dist/feature-flags/index.js",
"require": "./dist/feature-flags/index.js",
"node": "./dist/feature-flags/index.js"
}
},
"engines": {
@@ -73,6 +79,7 @@
"express-session": "^1.17.3",
"ioredis": "^5.2.5",
"ioredis-mock": "8.4.0",
"medusa-telemetry": "^0.0.18",
"morgan": "^1.9.1"
}
}
@@ -856,7 +856,7 @@ export type ConfigModule = {
*
* :::
*/
featureFlags: Record<string, boolean | string>
featureFlags: Record<string, boolean | string | Record<string, boolean>>
}
export type PluginDetails = {
@@ -0,0 +1,127 @@
import { FileSystem } from "@medusajs/utils"
import { join } from "path"
import { featureFlagsLoader } from "../feature-flag-loader"
import { configManager } from "../../config"
const filesystem = new FileSystem(join(__dirname, "__ff-test__"))
const buildFeatureFlag = (
key: string,
defaultVal: string | boolean
): string => {
const snakeCaseKey = key.replace(/-/g, "_")
return `
export default {
description: "${key} descr",
key: "${snakeCaseKey}",
env_key: "MEDUSA_FF_${snakeCaseKey.toUpperCase()}",
default_val: ${defaultVal},
}
`
}
describe("feature flags", () => {
const OLD_ENV = { ...process.env }
beforeEach(async () => {
jest.resetModules()
jest.clearAllMocks()
process.env = { ...OLD_ENV }
await filesystem.cleanup()
configManager.loadConfig({
projectConfig: {} as any,
baseDir: filesystem.basePath,
})
})
afterAll(async () => {
process.env = OLD_ENV
await filesystem.cleanup()
})
it("should load the flag from project", async () => {
configManager.loadConfig({
projectConfig: { featureFlags: { flag_1: false } },
baseDir: filesystem.basePath,
})
await filesystem.create("flags/flag-1.js", buildFeatureFlag("flag-1", true))
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
it("should load a nested + simple flag from project", async () => {
configManager.loadConfig({
projectConfig: {
featureFlags: { test: { nested: true }, simpletest: true },
},
baseDir: filesystem.basePath,
})
await filesystem.create("flags/test.js", buildFeatureFlag("test", false))
await filesystem.create(
"flags/simpletest.js",
buildFeatureFlag("simpletest", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
expect(flags.isFeatureEnabled({ test: "nested" })).toEqual(true)
expect(flags.isFeatureEnabled("simpletest")).toEqual(true)
})
it("should load the default feature flags", async () => {
await filesystem.create(
"flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
it("should load the flag from env", async () => {
process.env.MEDUSA_FF_FLAG_1 = "false"
await filesystem.create(
"flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
it("should load mix of flags", async () => {
configManager.loadConfig({
projectConfig: { featureFlags: { flag_2: false } },
baseDir: filesystem.basePath,
})
process.env.MEDUSA_FF_FLAG_3 = "true"
await filesystem.create(
"flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
await filesystem.create(
"flags/flag-2.js",
buildFeatureFlag("flag-2", false)
)
await filesystem.create(
"flags/flag-3.js",
buildFeatureFlag("flag-3", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
expect(flags.isFeatureEnabled("flag_2")).toEqual(false)
expect(flags.isFeatureEnabled("flag_3")).toEqual(true)
})
})
@@ -0,0 +1,129 @@
import {
ContainerRegistrationKeys,
FlagRouter,
isDefined,
isObject,
isString,
isTruthy,
objectFromStringPath,
} from "@medusajs/utils"
import { trackFeatureFlag } from "medusa-telemetry"
import { join, normalize } from "path"
import { logger } from "../logger"
import { FlagSettings } from "./types"
import { container } from "../container"
import { asFunction } from "awilix"
import { configManager } from "../config"
import { readdir } from "fs/promises"
export const featureFlagRouter = new FlagRouter({})
container.register(
ContainerRegistrationKeys.FEATURE_FLAG_ROUTER,
asFunction(() => featureFlagRouter)
)
const excludedFiles = ["index.js", "index.ts"]
const excludedExtensions = [".d.ts", ".d.ts.map", ".js.map"]
const flagConfig: Record<string, boolean | Record<string, boolean>> = {}
function registerFlag(
flag: FlagSettings,
projectConfigFlags: Record<string, string | boolean | Record<string, boolean>>
) {
flagConfig[flag.key] = isTruthy(flag.default_val)
let from
if (isDefined(process.env[flag.env_key])) {
from = "environment"
const envVal = process.env[flag.env_key]
// MEDUSA_FF_ANALYTICS="true"
flagConfig[flag.key] = isTruthy(process.env[flag.env_key])
const parsedFromEnv = isString(envVal) ? envVal.split(",") : []
// MEDUSA_FF_WORKFLOWS=createProducts,deleteProducts
if (parsedFromEnv.length > 1) {
flagConfig[flag.key] = objectFromStringPath(parsedFromEnv)
}
} else if (isDefined(projectConfigFlags[flag.key])) {
from = "project config"
// featureFlags: { analytics: "true" | true }
flagConfig[flag.key] = isTruthy(
projectConfigFlags[flag.key] as string | boolean
)
// featureFlags: { workflows: { createProducts: true } }
if (isObject(projectConfigFlags[flag.key])) {
flagConfig[flag.key] = projectConfigFlags[flag.key] as Record<
string,
boolean
>
}
}
if (logger && from) {
logger.info(
`Using flag ${flag.env_key} from ${from} with value ${
flagConfig[flag.key]
}`
)
}
if (flagConfig[flag.key]) {
trackFeatureFlag(flag.key)
}
featureFlagRouter.setFlag(flag.key, flagConfig[flag.key])
}
/**
* Load feature flags from a directory and from the already loaded config under the hood
* @param sourcePath
*/
export async function featureFlagsLoader(
sourcePath?: string
): Promise<FlagRouter> {
const { featureFlags: projectConfigFlags = {} } = configManager.config
if (!sourcePath) {
return featureFlagRouter
}
const flagDir = normalize(sourcePath)
await readdir(flagDir, { recursive: true, withFileTypes: true }).then(
async (files) => {
if (!files?.length) {
return
}
files.map(async (file) => {
if (file.isDirectory()) {
return await featureFlagsLoader(join(flagDir, file.name))
}
if (
excludedExtensions.some((ext) => file.name.endsWith(ext)) ||
excludedFiles.includes(file.name)
) {
return
}
const fileExports = await import(join(flagDir, file.name))
const featureFlag = fileExports.default
if (!featureFlag) {
return
}
registerFlag(featureFlag, projectConfigFlags)
return
})
}
)
return featureFlagRouter
}
@@ -0,0 +1,77 @@
import { isObject, isString } from "@medusajs/utils"
import { FeatureFlagsResponse, IFlagRouter } from "./types"
export class FlagRouter implements IFlagRouter {
private readonly flags: Record<string, boolean | Record<string, boolean>> = {}
constructor(flags: Record<string, boolean | Record<string, boolean>>) {
this.flags = flags
}
/**
* 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 | string[] | Record<string, string>
): boolean {
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]
}
const flags = (Array.isArray(flag) ? flag : [flag]) as string[]
return flags.every((flag_) => {
if (!isString(flag_)) {
throw Error("Flag must be a string an array of string or an object")
}
return !!this.flags[flag_]
})
}
/**
* 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 {
return Object.entries(this.flags || {}).map(([key, value]) => ({
key,
value,
}))
}
}
@@ -0,0 +1,3 @@
export * from "./types"
export * from "./feature-flag-loader"
export * from "./flag-router"
@@ -0,0 +1,32 @@
export interface IFlagRouter {
isFeatureEnabled: (key: string) => boolean
listFlags: () => FeatureFlagsResponse
}
/**
* @schema FeatureFlagsResponse
* type: array
* items:
* type: object
* required:
* - key
* - value
* properties:
* key:
* description: The key of the feature flag.
* type: string
* value:
* description: The value of the feature flag.
* type: boolean
*/
export type FeatureFlagsResponse = {
key: string
value: boolean | Record<string, boolean>
}[]
export type FlagSettings = {
key: string
description: string
env_key: string
default_val: boolean
}
@@ -3,3 +3,4 @@ export * from "./logger"
export * from "./http"
export * from "./database"
export * from "./container"
export * from "./feature-flags"