Feat(medusa): implement feature flags (#1768)
* feat: add feature flag loading in projects * fix: make feature flag consume itself * fix: rename container registration to featureFlagRouter * fix: refactor * behavioral feature flags * add environment to server * limit "useTemplateDb" to non feature flagged migrations * filter migrations and entities according to those which are enabled in the environment * run only migrations that are enabled when running 'medusa migrations run' * add logging to the featureflag loader * initial implementation of featureFlagEntity * column descriptors * initial startServerWithEnv (to be refactored) * update commands * final touches * update loaders to fix unit tests * enable all batch job tests * update seed method * add api test capabilities * revert batch job test * revert formatting changes * pr feedback * pr feedback * remove unused imports * rename feature flag decorators * pr feedback Co-authored-by: Sebastian Rindom <skrindom@gmail.com>
This commit is contained in:
@@ -95,25 +95,25 @@ describe("/admin/batch-jobs", () => {
|
||||
id: "job_5",
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
created_by: "admin_user"
|
||||
created_by: "admin_user",
|
||||
},
|
||||
{
|
||||
id: "job_3",
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
created_by: "admin_user"
|
||||
created_by: "admin_user",
|
||||
},
|
||||
{
|
||||
id: "job_2",
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
created_by: "admin_user"
|
||||
created_by: "admin_user",
|
||||
},
|
||||
{
|
||||
id: "job_1",
|
||||
created_at: expect.any(String),
|
||||
updated_at: expect.any(String),
|
||||
created_by: "admin_user"
|
||||
created_by: "admin_user",
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -121,7 +121,10 @@ describe("/admin/batch-jobs", () => {
|
||||
|
||||
it("lists batch jobs created by the user and where completed_at is null ", async () => {
|
||||
const api = useApi()
|
||||
const response = await api.get("/admin/batch-jobs?completed_at=null", adminReqConfig)
|
||||
const response = await api.get(
|
||||
"/admin/batch-jobs?completed_at=null",
|
||||
adminReqConfig
|
||||
)
|
||||
|
||||
expect(response.status).toEqual(200)
|
||||
expect(response.data.batch_jobs.length).toEqual(3)
|
||||
|
||||
@@ -2,7 +2,7 @@ const path = require("path")
|
||||
const { spawn } = require("child_process")
|
||||
const { setPort } = require("./use-api")
|
||||
|
||||
module.exports = ({ cwd, redisUrl, uploadDir, verbose }) => {
|
||||
module.exports = ({ cwd, redisUrl, uploadDir, verbose, env }) => {
|
||||
const serverPath = path.join(__dirname, "test-server.js")
|
||||
|
||||
// in order to prevent conflicts in redis, use a different db for each worker
|
||||
@@ -21,6 +21,7 @@ module.exports = ({ cwd, redisUrl, uploadDir, verbose }) => {
|
||||
COOKIE_SECRET: "test",
|
||||
REDIS_URL: redisUrl ? redisUrlWithDatabase : undefined, // If provided, will use a real instance, otherwise a fake instance
|
||||
UPLOAD_DIR: uploadDir, // If provided, will be used for the fake local file service
|
||||
...env,
|
||||
},
|
||||
stdio: verbose
|
||||
? ["inherit", "inherit", "inherit", "ipc"]
|
||||
|
||||
28
integration-tests/helpers/start-server-with-environment.js
Normal file
28
integration-tests/helpers/start-server-with-environment.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const setupServer = require("./setup-server")
|
||||
const { initDb } = require("./use-db")
|
||||
|
||||
const startServerWithEnvironment = async ({ cwd, verbose, env }) => {
|
||||
if (env) {
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
process.env[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
const dbConnection = await initDb({
|
||||
cwd,
|
||||
})
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
delete process.env[key]
|
||||
})
|
||||
|
||||
const medusaProcess = await setupServer({
|
||||
cwd,
|
||||
verbose,
|
||||
env,
|
||||
})
|
||||
|
||||
return [medusaProcess, dbConnection]
|
||||
}
|
||||
|
||||
export default startServerWithEnvironment
|
||||
@@ -79,6 +79,19 @@ const instance = DbTestUtil
|
||||
module.exports = {
|
||||
initDb: async function ({ cwd }) {
|
||||
const configPath = path.resolve(path.join(cwd, `medusa-config.js`))
|
||||
const { projectConfig, featureFlags } = require(configPath)
|
||||
|
||||
const featureFlagsLoader = require(path.join(
|
||||
cwd,
|
||||
`node_modules`,
|
||||
`@medusajs`,
|
||||
`medusa`,
|
||||
`dist`,
|
||||
`loaders`,
|
||||
`feature-flags`
|
||||
)).default
|
||||
|
||||
const featureFlagsRouter = featureFlagsLoader({ featureFlags })
|
||||
|
||||
const modelsLoader = require(path.join(
|
||||
cwd,
|
||||
@@ -89,9 +102,9 @@ module.exports = {
|
||||
`loaders`,
|
||||
`models`
|
||||
)).default
|
||||
|
||||
const entities = modelsLoader({}, { register: false })
|
||||
|
||||
const { projectConfig } = require(configPath)
|
||||
if (projectConfig.database_type === "sqlite") {
|
||||
connectionType = "sqlite"
|
||||
const dbConnection = await createConnection({
|
||||
@@ -108,12 +121,48 @@ module.exports = {
|
||||
|
||||
await dbFactory.createFromTemplate(databaseName)
|
||||
|
||||
// get migraitons with enabled featureflags
|
||||
const migrationDir = path.resolve(
|
||||
path.join(
|
||||
cwd,
|
||||
`node_modules`,
|
||||
`@medusajs`,
|
||||
`medusa`,
|
||||
`dist`,
|
||||
`migrations`,
|
||||
`*.js`
|
||||
)
|
||||
)
|
||||
|
||||
const { getEnabledMigrations } = require(path.join(
|
||||
cwd,
|
||||
`node_modules`,
|
||||
`@medusajs`,
|
||||
`medusa`,
|
||||
`dist`,
|
||||
`commands`,
|
||||
`utils`,
|
||||
`get-migrations`
|
||||
))
|
||||
|
||||
const enabledMigrations = await getEnabledMigrations(
|
||||
[migrationDir],
|
||||
(flag) => featureFlagsRouter.isFeatureEnabled(flag)
|
||||
)
|
||||
|
||||
const enabledEntities = entities.filter(
|
||||
(e) => typeof e.isFeatureEnabled === "undefined" || e.isFeatureEnabled()
|
||||
)
|
||||
|
||||
const dbConnection = await createConnection({
|
||||
type: "postgres",
|
||||
url: DB_URL,
|
||||
entities,
|
||||
entities: enabledEntities,
|
||||
migrations: enabledMigrations,
|
||||
})
|
||||
|
||||
await dbConnection.runMigrations()
|
||||
|
||||
instance.setDb(dbConnection)
|
||||
return dbConnection
|
||||
}
|
||||
|
||||
@@ -31,10 +31,28 @@ class DatabaseFactory {
|
||||
`@medusajs`,
|
||||
`medusa`,
|
||||
`dist`,
|
||||
`migrations`
|
||||
`migrations`,
|
||||
`*.js`
|
||||
)
|
||||
)
|
||||
|
||||
const { getEnabledMigrations } = require(path.join(
|
||||
cwd,
|
||||
`node_modules`,
|
||||
`@medusajs`,
|
||||
`medusa`,
|
||||
`dist`,
|
||||
`commands`,
|
||||
`utils`,
|
||||
`get-migrations`
|
||||
))
|
||||
|
||||
// filter migrations to only include those that dont have feature flags
|
||||
const enabledMigrations = await getEnabledMigrations(
|
||||
[migrationDir],
|
||||
(flag) => false
|
||||
)
|
||||
|
||||
await dropDatabase(
|
||||
{
|
||||
databaseName: this.templateDbName,
|
||||
@@ -51,7 +69,7 @@ class DatabaseFactory {
|
||||
type: "postgres",
|
||||
name: "templateConnection",
|
||||
url: `${DB_URL}/${this.templateDbName}`,
|
||||
migrations: [`${migrationDir}/*.js`],
|
||||
migrations: enabledMigrations,
|
||||
})
|
||||
|
||||
await templateDbConnection.runMigrations()
|
||||
@@ -92,7 +110,7 @@ class DatabaseFactory {
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
let connection = await this.getMasterConnection()
|
||||
const connection = await this.getMasterConnection()
|
||||
|
||||
await connection.query(`DROP DATABASE IF EXISTS "${this.templateDbName}";`)
|
||||
await connection.close()
|
||||
|
||||
18
packages/medusa/src/api/middlewares/feature-flag-enabled.ts
Normal file
18
packages/medusa/src/api/middlewares/feature-flag-enabled.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NextFunction, Request, Response } from "express"
|
||||
import { FlagRouter } from "../../utils/flag-router"
|
||||
|
||||
export function isFeatureFlagEnabled(
|
||||
flagKey: string
|
||||
): (req: Request, res: Response, next: NextFunction) => Promise<void> {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const featureFlagRouter = req.scope.resolve(
|
||||
"featureFlagRouter"
|
||||
) as FlagRouter
|
||||
|
||||
if (!featureFlagRouter.isFeatureEnabled(flagKey)) {
|
||||
res.sendStatus(404)
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,27 @@
|
||||
import { createConnection } from "typeorm"
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
|
||||
import featureFlagLoader from "../loaders/feature-flags"
|
||||
import Logger from "../loaders/logger"
|
||||
|
||||
import getMigrations from "./utils/get-migrations"
|
||||
|
||||
const t = async function({ directory }) {
|
||||
const t = async function ({ directory }) {
|
||||
const args = process.argv
|
||||
args.shift()
|
||||
args.shift()
|
||||
args.shift()
|
||||
|
||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||
const migrationDirs = getMigrations(directory)
|
||||
|
||||
const featureFlagRouter = featureFlagLoader(configModule)
|
||||
|
||||
const enabledMigrations = await getMigrations(directory, featureFlagRouter)
|
||||
|
||||
const connection = await createConnection({
|
||||
type: configModule.projectConfig.database_type,
|
||||
url: configModule.projectConfig.database_url,
|
||||
extra: configModule.projectConfig.database_extra || {},
|
||||
migrations: migrationDirs,
|
||||
migrations: enabledMigrations,
|
||||
logging: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,9 +9,11 @@ import { track } from "medusa-telemetry"
|
||||
import Logger from "../loaders/logger"
|
||||
import loaders from "../loaders"
|
||||
|
||||
import featureFlagLoader from "../loaders/feature-flags"
|
||||
|
||||
import getMigrations from "./utils/get-migrations"
|
||||
|
||||
const t = async function({ directory, migrate, seedFile }) {
|
||||
const t = async function ({ directory, migrate, seedFile }) {
|
||||
track("CLI_SEED")
|
||||
let resolvedPath = seedFile
|
||||
|
||||
@@ -28,9 +30,12 @@ const t = async function({ directory, migrate, seedFile }) {
|
||||
}
|
||||
|
||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||
|
||||
const featureFlagRouter = featureFlagLoader(configModule)
|
||||
|
||||
const dbType = configModule.projectConfig.database_type
|
||||
if (migrate && dbType !== "sqlite") {
|
||||
const migrationDirs = getMigrations(directory)
|
||||
const migrationDirs = await getMigrations(directory, featureFlagRouter)
|
||||
const connection = await createConnection({
|
||||
type: configModule.projectConfig.database_type,
|
||||
database: configModule.projectConfig.database_database,
|
||||
@@ -61,7 +66,7 @@ const t = async function({ directory, migrate, seedFile }) {
|
||||
const shippingOptionService = container.resolve("shippingOptionService")
|
||||
const shippingProfileService = container.resolve("shippingProfileService")
|
||||
|
||||
await manager.transaction(async tx => {
|
||||
await manager.transaction(async (tx) => {
|
||||
const { store, regions, products, shipping_options, users } = JSON.parse(
|
||||
fs.readFileSync(resolvedPath, `utf-8`)
|
||||
)
|
||||
@@ -74,14 +79,14 @@ const t = async function({ directory, migrate, seedFile }) {
|
||||
}
|
||||
|
||||
for (const u of users) {
|
||||
let pass = u.password
|
||||
const pass = u.password
|
||||
if (pass) {
|
||||
delete u.password
|
||||
}
|
||||
await userService.withTransaction(tx).create(u, pass)
|
||||
}
|
||||
|
||||
let regionIds = {}
|
||||
const regionIds = {}
|
||||
for (const r of regions) {
|
||||
let dummyId
|
||||
if (!r.id || !r.id.startsWith("reg_")) {
|
||||
@@ -126,7 +131,7 @@ const t = async function({ directory, migrate, seedFile }) {
|
||||
|
||||
if (variants && variants.length) {
|
||||
const optionIds = p.options.map(
|
||||
o => newProd.options.find(newO => newO.title === o.title).id
|
||||
(o) => newProd.options.find((newO) => newO.title === o.title).id
|
||||
)
|
||||
|
||||
for (const v of variants) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import glob from "glob"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
import { isString } from "lodash"
|
||||
@@ -33,7 +34,7 @@ function resolvePlugin(pluginName) {
|
||||
fs.readFileSync(`${resolvedPath}/package.json`, `utf-8`)
|
||||
)
|
||||
const name = packageJSON.name || pluginName
|
||||
//warnOnIncompatiblePeerDependency(name, packageJSON)
|
||||
// warnOnIncompatiblePeerDependency(name, packageJSON)
|
||||
|
||||
return {
|
||||
resolve: resolvedPath,
|
||||
@@ -86,11 +87,11 @@ function resolvePlugin(pluginName) {
|
||||
}
|
||||
}
|
||||
|
||||
export default directory => {
|
||||
export default async (directory, featureFlagRouter) => {
|
||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||
const { plugins } = configModule
|
||||
|
||||
const resolved = plugins.map(plugin => {
|
||||
const resolved = plugins.map((plugin) => {
|
||||
if (isString(plugin)) {
|
||||
return resolvePlugin(plugin)
|
||||
}
|
||||
@@ -123,5 +124,26 @@ export default directory => {
|
||||
}
|
||||
}
|
||||
|
||||
return migrationDirs
|
||||
return getEnabledMigrations(migrationDirs, (flag) =>
|
||||
featureFlagRouter.isFeatureEnabled(flag)
|
||||
)
|
||||
}
|
||||
|
||||
export const getEnabledMigrations = (migrationDirs, isFlagEnabled) => {
|
||||
const allMigrations = migrationDirs.flatMap((dir) => {
|
||||
return glob.sync(dir)
|
||||
})
|
||||
return allMigrations
|
||||
.map((file) => {
|
||||
const loaded = require(file)
|
||||
if (
|
||||
typeof loaded.featureFlag === "undefined" ||
|
||||
isFlagEnabled(loaded.featureFlag)
|
||||
) {
|
||||
return file
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import supertest from "supertest"
|
||||
import querystring from "querystring"
|
||||
import apiLoader from "../loaders/api"
|
||||
import passportLoader from "../loaders/passport"
|
||||
import featureFlagLoader from "../loaders/feature-flags"
|
||||
import servicesLoader from "../loaders/services"
|
||||
import strategiesLoader from "../loaders/strategies"
|
||||
|
||||
@@ -24,17 +25,21 @@ const clientSessionOpts = {
|
||||
|
||||
const config = {
|
||||
projectConfig: {
|
||||
jwt_secret: 'supersecret',
|
||||
cookie_secret: 'superSecret',
|
||||
admin_cors: '',
|
||||
store_cors: ''
|
||||
}
|
||||
jwt_secret: "supersecret",
|
||||
cookie_secret: "superSecret",
|
||||
admin_cors: "",
|
||||
store_cors: "",
|
||||
},
|
||||
}
|
||||
|
||||
const testApp = express()
|
||||
|
||||
const container = createContainer()
|
||||
container.register('configModule', asValue(config))
|
||||
|
||||
const featureFlagRouter = featureFlagLoader(config)
|
||||
|
||||
container.register("featureFlagRouter", asValue(featureFlagRouter))
|
||||
container.register("configModule", asValue(config))
|
||||
container.register({
|
||||
logger: asValue({
|
||||
error: () => {},
|
||||
@@ -69,10 +74,16 @@ apiLoader({ container, app: testApp, configModule: config })
|
||||
const supertestRequest = supertest(testApp)
|
||||
|
||||
export async function request(method, url, opts = {}) {
|
||||
const { payload, query, headers = {} } = opts
|
||||
const { payload, query, headers = {}, flags = [] } = opts
|
||||
|
||||
const queryParams = query && querystring.stringify(query);
|
||||
const req = supertestRequest[method.toLowerCase()](`${url}${queryParams ? "?" + queryParams : ''}`)
|
||||
flags.forEach((flag) => {
|
||||
featureFlagRouter.setFlag(flag, true)
|
||||
})
|
||||
|
||||
const queryParams = query && querystring.stringify(query)
|
||||
const req = supertestRequest[method.toLowerCase()](
|
||||
`${url}${queryParams ? "?" + queryParams : ""}`
|
||||
)
|
||||
headers.Cookie = headers.Cookie || ""
|
||||
if (opts.adminSession) {
|
||||
if (opts.adminSession.jwt) {
|
||||
|
||||
130
packages/medusa/src/loaders/__tests__/feature-flags.spec.ts
Normal file
130
packages/medusa/src/loaders/__tests__/feature-flags.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { resolve } from "path"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
|
||||
import loadFeatureFlags from "../feature-flags"
|
||||
|
||||
const distTestTargetDirectorPath = resolve(__dirname, "__ff-test__")
|
||||
|
||||
const getFolderTestTargetDirectoryPath = (folderName: string): string => {
|
||||
return resolve(distTestTargetDirectorPath, folderName)
|
||||
}
|
||||
|
||||
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(() => {
|
||||
jest.resetModules()
|
||||
jest.clearAllMocks()
|
||||
|
||||
process.env = { ...OLD_ENV }
|
||||
|
||||
rmSync(distTestTargetDirectorPath, { recursive: true, force: true })
|
||||
|
||||
mkdirSync(getFolderTestTargetDirectoryPath("project"), {
|
||||
mode: "777",
|
||||
recursive: true,
|
||||
})
|
||||
|
||||
mkdirSync(getFolderTestTargetDirectoryPath("flags"), {
|
||||
mode: "777",
|
||||
recursive: true,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env = OLD_ENV
|
||||
rmSync(distTestTargetDirectorPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("should load the flag from project", async () => {
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-1.js"),
|
||||
buildFeatureFlag("flag-1", true)
|
||||
)
|
||||
|
||||
const flags = await loadFeatureFlags(
|
||||
{ featureFlags: { flag_1: false } },
|
||||
undefined,
|
||||
getFolderTestTargetDirectoryPath("flags")
|
||||
)
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load the default feature flags", async () => {
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-1.js"),
|
||||
buildFeatureFlag("flag-1", true)
|
||||
)
|
||||
|
||||
const flags = await loadFeatureFlags(
|
||||
{},
|
||||
undefined,
|
||||
getFolderTestTargetDirectoryPath("flags")
|
||||
)
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should load the flag from env", async () => {
|
||||
process.env.MEDUSA_FF_FLAG_1 = "false"
|
||||
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-1.js"),
|
||||
buildFeatureFlag("flag-1", true)
|
||||
)
|
||||
|
||||
const flags = await loadFeatureFlags(
|
||||
{},
|
||||
undefined,
|
||||
getFolderTestTargetDirectoryPath("flags")
|
||||
)
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load mix of flags", async () => {
|
||||
process.env.MEDUSA_FF_FLAG_3 = "false"
|
||||
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-1.js"),
|
||||
buildFeatureFlag("flag-1", true)
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-2.js"),
|
||||
buildFeatureFlag("flag-2", true)
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
resolve(getFolderTestTargetDirectoryPath("flags"), "flag-3.js"),
|
||||
buildFeatureFlag("flag-3", true)
|
||||
)
|
||||
|
||||
const flags = await loadFeatureFlags(
|
||||
{ featureFlags: { flag_2: false } },
|
||||
undefined,
|
||||
getFolderTestTargetDirectoryPath("flags")
|
||||
)
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(true)
|
||||
expect(flags.isFeatureEnabled("flag_2")).toEqual(false)
|
||||
expect(flags.isFeatureEnabled("flag_3")).toEqual(false)
|
||||
})
|
||||
})
|
||||
@@ -56,6 +56,7 @@ export default (rootDirectory: string): ConfigModule => {
|
||||
cookie_secret: cookie_secret ?? "supersecret",
|
||||
...configModule?.projectConfig,
|
||||
},
|
||||
featureFlags: configModule?.featureFlags ?? {},
|
||||
plugins: configModule?.plugins ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
68
packages/medusa/src/loaders/feature-flags/index.ts
Normal file
68
packages/medusa/src/loaders/feature-flags/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import path from "path"
|
||||
import glob from "glob"
|
||||
|
||||
import { FlagSettings } from "../../types/feature-flags"
|
||||
import { FlagRouter } from "../../utils/flag-router"
|
||||
import { Logger } from "../../types/global"
|
||||
|
||||
const isTruthy = (val: string | boolean | undefined): boolean => {
|
||||
if (typeof val === "string") {
|
||||
return val.toLowerCase() === "true"
|
||||
}
|
||||
return !!val
|
||||
}
|
||||
|
||||
export default (
|
||||
configModule: { featureFlags?: Record<string, string | boolean> } = {},
|
||||
logger?: Logger,
|
||||
flagDirectory?: string
|
||||
): FlagRouter => {
|
||||
const { featureFlags: projectConfigFlags = {} } = configModule
|
||||
|
||||
const flagDir = path.join(flagDirectory || __dirname, "*.js")
|
||||
const supportedFlags = glob.sync(flagDir, {
|
||||
ignore: ["**/index.js"],
|
||||
})
|
||||
|
||||
const flagConfig: Record<string, boolean> = {}
|
||||
for (const flag of supportedFlags) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const importedModule = require(flag)
|
||||
if (!importedModule.default) {
|
||||
continue
|
||||
}
|
||||
|
||||
const flagSettings: FlagSettings = importedModule.default
|
||||
|
||||
switch (true) {
|
||||
case typeof process.env[flagSettings.env_key] !== "undefined":
|
||||
if (logger) {
|
||||
logger.info(
|
||||
`Using flag ${flagSettings.env_key} from environment with value ${
|
||||
process.env[flagSettings.env_key]
|
||||
}`
|
||||
)
|
||||
}
|
||||
flagConfig[flagSettings.key] = isTruthy(
|
||||
process.env[flagSettings.env_key]
|
||||
)
|
||||
break
|
||||
case typeof projectConfigFlags[flagSettings.key] !== "undefined":
|
||||
if (logger) {
|
||||
logger.info(
|
||||
`Using flag ${flagSettings.key} from project config with value ${
|
||||
projectConfigFlags[flagSettings.key]
|
||||
}`
|
||||
)
|
||||
}
|
||||
flagConfig[flagSettings.key] = isTruthy(
|
||||
projectConfigFlags[flagSettings.key]
|
||||
)
|
||||
break
|
||||
default:
|
||||
flagConfig[flagSettings.key] = flagSettings.default_val
|
||||
}
|
||||
}
|
||||
|
||||
return new FlagRouter(flagConfig)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import loadConfig from './config'
|
||||
import loadConfig from "./config"
|
||||
import "reflect-metadata"
|
||||
import Logger from "./logger"
|
||||
import apiLoader from "./api"
|
||||
import featureFlagsLoader from "./feature-flags"
|
||||
import databaseLoader from "./database"
|
||||
import defaultsLoader from "./defaults"
|
||||
import expressLoader from "./express"
|
||||
import expressLoader from "./express"
|
||||
import modelsLoader from "./models"
|
||||
import passportLoader from "./passport"
|
||||
import pluginsLoader, { registerPluginModels } from "./plugins"
|
||||
@@ -18,35 +19,50 @@ import subscribersLoader from "./subscribers"
|
||||
import { ClassOrFunctionReturning } from "awilix/lib/container"
|
||||
import { Connection, getManager } from "typeorm"
|
||||
import { Express, NextFunction, Request, Response } from "express"
|
||||
import { asFunction, asValue, AwilixContainer, createContainer, Resolver } from "awilix"
|
||||
import {
|
||||
asFunction,
|
||||
asValue,
|
||||
AwilixContainer,
|
||||
createContainer,
|
||||
Resolver,
|
||||
} from "awilix"
|
||||
import { track } from "medusa-telemetry"
|
||||
import { MedusaContainer } from "../types/global"
|
||||
|
||||
type Options = {
|
||||
directory: string;
|
||||
expressApp: Express;
|
||||
directory: string
|
||||
expressApp: Express
|
||||
isTest: boolean
|
||||
}
|
||||
|
||||
export default async (
|
||||
{
|
||||
directory: rootDirectory,
|
||||
expressApp,
|
||||
isTest
|
||||
}: Options
|
||||
): Promise<{ container: MedusaContainer; dbConnection: Connection; app: Express }> => {
|
||||
export default async ({
|
||||
directory: rootDirectory,
|
||||
expressApp,
|
||||
isTest,
|
||||
}: Options): Promise<{
|
||||
container: MedusaContainer
|
||||
dbConnection: Connection
|
||||
app: Express
|
||||
}> => {
|
||||
const configModule = loadConfig(rootDirectory)
|
||||
|
||||
const container = createContainer() as MedusaContainer
|
||||
container.register('configModule', asValue(configModule))
|
||||
container.register("configModule", asValue(configModule))
|
||||
|
||||
container.registerAdd = function (this: MedusaContainer, name: string, registration: typeof asFunction | typeof asValue) {
|
||||
container.registerAdd = function (
|
||||
this: MedusaContainer,
|
||||
name: string,
|
||||
registration: typeof asFunction | typeof asValue
|
||||
) {
|
||||
const storeKey = name + "_STORE"
|
||||
|
||||
if (this.registrations[storeKey] === undefined) {
|
||||
this.register(storeKey, asValue([] as Resolver<unknown>[]))
|
||||
}
|
||||
const store = this.resolve(storeKey) as (ClassOrFunctionReturning<unknown> | Resolver<unknown>)[]
|
||||
const store = this.resolve(storeKey) as (
|
||||
| ClassOrFunctionReturning<unknown>
|
||||
| Resolver<unknown>
|
||||
)[]
|
||||
|
||||
if (this.registrations[name] === undefined) {
|
||||
this.register(name, asArray(store))
|
||||
@@ -59,15 +75,17 @@ export default async (
|
||||
// Add additional information to context of request
|
||||
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const ipAddress = requestIp.getClientIp(req) as string
|
||||
|
||||
(req as any).request_context = {
|
||||
;(req as any).request_context = {
|
||||
ip_address: ipAddress,
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
const featureFlagRouter = featureFlagsLoader(configModule, Logger)
|
||||
|
||||
container.register({
|
||||
logger: asValue(Logger)
|
||||
logger: asValue(Logger),
|
||||
featureFlagRouter: asValue(featureFlagRouter),
|
||||
})
|
||||
|
||||
await redisLoader({ container, configModule, logger: Logger })
|
||||
@@ -83,7 +101,7 @@ export default async (
|
||||
await registerPluginModels({
|
||||
rootDirectory,
|
||||
container,
|
||||
configModule
|
||||
configModule,
|
||||
})
|
||||
const pmAct = Logger.success(pmActivity, "Plugin models initialized") || {}
|
||||
track("PLUGIN_MODELS_INIT_COMPLETED", { duration: pmAct.duration })
|
||||
@@ -100,7 +118,7 @@ export default async (
|
||||
const dbAct = Logger.success(dbActivity, "Database initialized") || {}
|
||||
track("DATABASE_INIT_COMPLETED", { duration: dbAct.duration })
|
||||
|
||||
container.register({ manager: asValue(dbConnection.manager), })
|
||||
container.register({ manager: asValue(dbConnection.manager) })
|
||||
|
||||
const stratActivity = Logger.activity("Initializing strategies")
|
||||
track("STRATEGIES_INIT_STARTED")
|
||||
@@ -123,8 +141,8 @@ export default async (
|
||||
|
||||
// Add the registered services to the request scope
|
||||
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
||||
container.register({ manager: asValue(getManager()) });
|
||||
(req as any).scope = container.createScope()
|
||||
container.register({ manager: asValue(getManager()) })
|
||||
;(req as any).scope = container.createScope()
|
||||
next()
|
||||
})
|
||||
|
||||
|
||||
10
packages/medusa/src/types/feature-flags.ts
Normal file
10
packages/medusa/src/types/feature-flags.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface IFlagRouter {
|
||||
isFeatureEnabled: (key: string) => boolean
|
||||
}
|
||||
|
||||
export type FlagSettings = {
|
||||
key: string
|
||||
description: string
|
||||
env_key: string
|
||||
default_val: boolean
|
||||
}
|
||||
@@ -52,6 +52,7 @@ export type ConfigModule = {
|
||||
store_cors?: string
|
||||
admin_cors?: string
|
||||
}
|
||||
featureFlags: Record<string, boolean | string>
|
||||
plugins: (
|
||||
| {
|
||||
resolve: string
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
import path from "path"
|
||||
import { Column, ColumnOptions, ColumnType } from "typeorm"
|
||||
import path from "path"
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
|
||||
const pgSqliteTypeMapping: { [key: string]: ColumnType } = {
|
||||
increment: "rowid",
|
||||
|
||||
66
packages/medusa/src/utils/feature-flag-decorators.ts
Normal file
66
packages/medusa/src/utils/feature-flag-decorators.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { getConfigFile } from "medusa-core-utils"
|
||||
import { Column, ColumnOptions, Entity, EntityOptions } from "typeorm"
|
||||
import featureFlagsLoader from "../loaders/feature-flags"
|
||||
import path from "path"
|
||||
import { ConfigModule } from "../types/global"
|
||||
import { FlagRouter } from "./flag-router"
|
||||
|
||||
export function FeatureFlagColumn(
|
||||
featureFlag: string,
|
||||
columnOptions: ColumnOptions
|
||||
): PropertyDecorator {
|
||||
const featureFlagRouter = getFeatureFlagRouter()
|
||||
|
||||
if (!featureFlagRouter.isFeatureEnabled(featureFlag)) {
|
||||
return (): void => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
|
||||
return Column(columnOptions)
|
||||
}
|
||||
|
||||
export function FeatureFlagDecorators(
|
||||
featureFlag: string,
|
||||
decorators: PropertyDecorator[]
|
||||
): PropertyDecorator {
|
||||
const featureFlagRouter = getFeatureFlagRouter()
|
||||
|
||||
if (!featureFlagRouter.isFeatureEnabled(featureFlag)) {
|
||||
return (): void => {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return (target: Object, propertyKey: string | symbol): void => {
|
||||
decorators.forEach((decorator) => {
|
||||
decorator(target, propertyKey)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function FeatureFlagEntity(
|
||||
featureFlag: string,
|
||||
name?: string,
|
||||
options?: EntityOptions
|
||||
): ClassDecorator {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
return function (target: Function): void {
|
||||
target["isFeatureEnabled"] = function (): boolean {
|
||||
const featureFlagRouter = getFeatureFlagRouter()
|
||||
|
||||
// const featureFlagRouter = featureFlagsLoader(configModule)
|
||||
return featureFlagRouter.isFeatureEnabled(featureFlag)
|
||||
}
|
||||
Entity(name, options)(target)
|
||||
}
|
||||
}
|
||||
|
||||
function getFeatureFlagRouter(): FlagRouter {
|
||||
const { configModule } = getConfigFile(
|
||||
path.resolve("."),
|
||||
`medusa-config`
|
||||
) as { configModule: ConfigModule }
|
||||
|
||||
return featureFlagsLoader(configModule)
|
||||
}
|
||||
17
packages/medusa/src/utils/flag-router.ts
Normal file
17
packages/medusa/src/utils/flag-router.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IFlagRouter } from "../types/feature-flags"
|
||||
|
||||
export class FlagRouter implements IFlagRouter {
|
||||
private flags: Record<string, boolean> = {}
|
||||
|
||||
constructor(flags: Record<string, boolean>) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
public isFeatureEnabled(key: string): boolean {
|
||||
return !!this.flags[key]
|
||||
}
|
||||
|
||||
public setFlag(key: string, value = true): void {
|
||||
this.flags[key] = value
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user