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
2023-08-07 11:38:25 +02:00
committed by GitHub
parent 03fb0479c0
commit 5c60aad177
17 changed files with 478 additions and 115 deletions

View File

@@ -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)
}
/**

View File

@@ -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"

View 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
}

View File

@@ -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"

View File

@@ -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,
},
])
})
})

View File

@@ -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
}

View 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",
}