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:
1 parent
f0704a7e17
commit
41681b45b1
19 files changed
+521
-52
No files matched your search
@@ -95,25 +95,25 @@ describe("/admin/batch-jobs", () => {
|
|||||||
id: "job_5",
|
id: "job_5",
|
||||||
created_at: expect.any(String),
|
created_at: expect.any(String),
|
||||||
updated_at: expect.any(String),
|
updated_at: expect.any(String),
|
||||||
created_by: "admin_user"
|
created_by: "admin_user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "job_3",
|
id: "job_3",
|
||||||
created_at: expect.any(String),
|
created_at: expect.any(String),
|
||||||
updated_at: expect.any(String),
|
updated_at: expect.any(String),
|
||||||
created_by: "admin_user"
|
created_by: "admin_user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "job_2",
|
id: "job_2",
|
||||||
created_at: expect.any(String),
|
created_at: expect.any(String),
|
||||||
updated_at: expect.any(String),
|
updated_at: expect.any(String),
|
||||||
created_by: "admin_user"
|
created_by: "admin_user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "job_1",
|
id: "job_1",
|
||||||
created_at: expect.any(String),
|
created_at: expect.any(String),
|
||||||
updated_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 () => {
|
it("lists batch jobs created by the user and where completed_at is null ", async () => {
|
||||||
const api = useApi()
|
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.status).toEqual(200)
|
||||||
expect(response.data.batch_jobs.length).toEqual(3)
|
expect(response.data.batch_jobs.length).toEqual(3)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const path = require("path")
|
|||||||
const { spawn } = require("child_process")
|
const { spawn } = require("child_process")
|
||||||
const { setPort } = require("./use-api")
|
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")
|
const serverPath = path.join(__dirname, "test-server.js")
|
||||||
|
|
||||||
// in order to prevent conflicts in redis, use a different db for each worker
|
// 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",
|
COOKIE_SECRET: "test",
|
||||||
REDIS_URL: redisUrl ? redisUrlWithDatabase : undefined, // If provided, will use a real instance, otherwise a fake instance
|
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
|
UPLOAD_DIR: uploadDir, // If provided, will be used for the fake local file service
|
||||||
|
...env,
|
||||||
},
|
},
|
||||||
stdio: verbose
|
stdio: verbose
|
||||||
? ["inherit", "inherit", "inherit", "ipc"]
|
? ["inherit", "inherit", "inherit", "ipc"]
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
initDb: async function ({ cwd }) {
|
initDb: async function ({ cwd }) {
|
||||||
const configPath = path.resolve(path.join(cwd, `medusa-config.js`))
|
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(
|
const modelsLoader = require(path.join(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -89,9 +102,9 @@ module.exports = {
|
|||||||
`loaders`,
|
`loaders`,
|
||||||
`models`
|
`models`
|
||||||
)).default
|
)).default
|
||||||
|
|
||||||
const entities = modelsLoader({}, { register: false })
|
const entities = modelsLoader({}, { register: false })
|
||||||
|
|
||||||
const { projectConfig } = require(configPath)
|
|
||||||
if (projectConfig.database_type === "sqlite") {
|
if (projectConfig.database_type === "sqlite") {
|
||||||
connectionType = "sqlite"
|
connectionType = "sqlite"
|
||||||
const dbConnection = await createConnection({
|
const dbConnection = await createConnection({
|
||||||
@@ -108,12 +121,48 @@ module.exports = {
|
|||||||
|
|
||||||
await dbFactory.createFromTemplate(databaseName)
|
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({
|
const dbConnection = await createConnection({
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
url: DB_URL,
|
url: DB_URL,
|
||||||
entities,
|
entities: enabledEntities,
|
||||||
|
migrations: enabledMigrations,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await dbConnection.runMigrations()
|
||||||
|
|
||||||
instance.setDb(dbConnection)
|
instance.setDb(dbConnection)
|
||||||
return dbConnection
|
return dbConnection
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,28 @@ class DatabaseFactory {
|
|||||||
`@medusajs`,
|
`@medusajs`,
|
||||||
`medusa`,
|
`medusa`,
|
||||||
`dist`,
|
`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(
|
await dropDatabase(
|
||||||
{
|
{
|
||||||
databaseName: this.templateDbName,
|
databaseName: this.templateDbName,
|
||||||
@@ -51,7 +69,7 @@ class DatabaseFactory {
|
|||||||
type: "postgres",
|
type: "postgres",
|
||||||
name: "templateConnection",
|
name: "templateConnection",
|
||||||
url: `${DB_URL}/${this.templateDbName}`,
|
url: `${DB_URL}/${this.templateDbName}`,
|
||||||
migrations: [`${migrationDir}/*.js`],
|
migrations: enabledMigrations,
|
||||||
})
|
})
|
||||||
|
|
||||||
await templateDbConnection.runMigrations()
|
await templateDbConnection.runMigrations()
|
||||||
@@ -92,7 +110,7 @@ class DatabaseFactory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async destroy() {
|
async destroy() {
|
||||||
let connection = await this.getMasterConnection()
|
const connection = await this.getMasterConnection()
|
||||||
|
|
||||||
await connection.query(`DROP DATABASE IF EXISTS "${this.templateDbName}";`)
|
await connection.query(`DROP DATABASE IF EXISTS "${this.templateDbName}";`)
|
||||||
await connection.close()
|
await connection.close()
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import { createConnection } from "typeorm"
|
import { createConnection } from "typeorm"
|
||||||
import { getConfigFile } from "medusa-core-utils"
|
import { getConfigFile } from "medusa-core-utils"
|
||||||
|
import featureFlagLoader from "../loaders/feature-flags"
|
||||||
import Logger from "../loaders/logger"
|
import Logger from "../loaders/logger"
|
||||||
|
|
||||||
import getMigrations from "./utils/get-migrations"
|
import getMigrations from "./utils/get-migrations"
|
||||||
@@ -12,13 +12,16 @@ const t = async function({ directory }) {
|
|||||||
args.shift()
|
args.shift()
|
||||||
|
|
||||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||||
const migrationDirs = getMigrations(directory)
|
|
||||||
|
const featureFlagRouter = featureFlagLoader(configModule)
|
||||||
|
|
||||||
|
const enabledMigrations = await getMigrations(directory, featureFlagRouter)
|
||||||
|
|
||||||
const connection = await createConnection({
|
const connection = await createConnection({
|
||||||
type: configModule.projectConfig.database_type,
|
type: configModule.projectConfig.database_type,
|
||||||
url: configModule.projectConfig.database_url,
|
url: configModule.projectConfig.database_url,
|
||||||
extra: configModule.projectConfig.database_extra || {},
|
extra: configModule.projectConfig.database_extra || {},
|
||||||
migrations: migrationDirs,
|
migrations: enabledMigrations,
|
||||||
logging: true,
|
logging: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { track } from "medusa-telemetry"
|
|||||||
import Logger from "../loaders/logger"
|
import Logger from "../loaders/logger"
|
||||||
import loaders from "../loaders"
|
import loaders from "../loaders"
|
||||||
|
|
||||||
|
import featureFlagLoader from "../loaders/feature-flags"
|
||||||
|
|
||||||
import getMigrations from "./utils/get-migrations"
|
import getMigrations from "./utils/get-migrations"
|
||||||
|
|
||||||
const t = async function ({ directory, migrate, seedFile }) {
|
const t = async function ({ directory, migrate, seedFile }) {
|
||||||
@@ -28,9 +30,12 @@ const t = async function({ directory, migrate, seedFile }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||||
|
|
||||||
|
const featureFlagRouter = featureFlagLoader(configModule)
|
||||||
|
|
||||||
const dbType = configModule.projectConfig.database_type
|
const dbType = configModule.projectConfig.database_type
|
||||||
if (migrate && dbType !== "sqlite") {
|
if (migrate && dbType !== "sqlite") {
|
||||||
const migrationDirs = getMigrations(directory)
|
const migrationDirs = await getMigrations(directory, featureFlagRouter)
|
||||||
const connection = await createConnection({
|
const connection = await createConnection({
|
||||||
type: configModule.projectConfig.database_type,
|
type: configModule.projectConfig.database_type,
|
||||||
database: configModule.projectConfig.database_database,
|
database: configModule.projectConfig.database_database,
|
||||||
@@ -61,7 +66,7 @@ const t = async function({ directory, migrate, seedFile }) {
|
|||||||
const shippingOptionService = container.resolve("shippingOptionService")
|
const shippingOptionService = container.resolve("shippingOptionService")
|
||||||
const shippingProfileService = container.resolve("shippingProfileService")
|
const shippingProfileService = container.resolve("shippingProfileService")
|
||||||
|
|
||||||
await manager.transaction(async tx => {
|
await manager.transaction(async (tx) => {
|
||||||
const { store, regions, products, shipping_options, users } = JSON.parse(
|
const { store, regions, products, shipping_options, users } = JSON.parse(
|
||||||
fs.readFileSync(resolvedPath, `utf-8`)
|
fs.readFileSync(resolvedPath, `utf-8`)
|
||||||
)
|
)
|
||||||
@@ -74,14 +79,14 @@ const t = async function({ directory, migrate, seedFile }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const u of users) {
|
for (const u of users) {
|
||||||
let pass = u.password
|
const pass = u.password
|
||||||
if (pass) {
|
if (pass) {
|
||||||
delete u.password
|
delete u.password
|
||||||
}
|
}
|
||||||
await userService.withTransaction(tx).create(u, pass)
|
await userService.withTransaction(tx).create(u, pass)
|
||||||
}
|
}
|
||||||
|
|
||||||
let regionIds = {}
|
const regionIds = {}
|
||||||
for (const r of regions) {
|
for (const r of regions) {
|
||||||
let dummyId
|
let dummyId
|
||||||
if (!r.id || !r.id.startsWith("reg_")) {
|
if (!r.id || !r.id.startsWith("reg_")) {
|
||||||
@@ -126,7 +131,7 @@ const t = async function({ directory, migrate, seedFile }) {
|
|||||||
|
|
||||||
if (variants && variants.length) {
|
if (variants && variants.length) {
|
||||||
const optionIds = p.options.map(
|
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) {
|
for (const v of variants) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import glob from "glob"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs"
|
import fs from "fs"
|
||||||
import { isString } from "lodash"
|
import { isString } from "lodash"
|
||||||
@@ -86,11 +87,11 @@ function resolvePlugin(pluginName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default directory => {
|
export default async (directory, featureFlagRouter) => {
|
||||||
const { configModule } = getConfigFile(directory, `medusa-config`)
|
const { configModule } = getConfigFile(directory, `medusa-config`)
|
||||||
const { plugins } = configModule
|
const { plugins } = configModule
|
||||||
|
|
||||||
const resolved = plugins.map(plugin => {
|
const resolved = plugins.map((plugin) => {
|
||||||
if (isString(plugin)) {
|
if (isString(plugin)) {
|
||||||
return resolvePlugin(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 querystring from "querystring"
|
||||||
import apiLoader from "../loaders/api"
|
import apiLoader from "../loaders/api"
|
||||||
import passportLoader from "../loaders/passport"
|
import passportLoader from "../loaders/passport"
|
||||||
|
import featureFlagLoader from "../loaders/feature-flags"
|
||||||
import servicesLoader from "../loaders/services"
|
import servicesLoader from "../loaders/services"
|
||||||
import strategiesLoader from "../loaders/strategies"
|
import strategiesLoader from "../loaders/strategies"
|
||||||
|
|
||||||
@@ -24,17 +25,21 @@ const clientSessionOpts = {
|
|||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
projectConfig: {
|
projectConfig: {
|
||||||
jwt_secret: 'supersecret',
|
jwt_secret: "supersecret",
|
||||||
cookie_secret: 'superSecret',
|
cookie_secret: "superSecret",
|
||||||
admin_cors: '',
|
admin_cors: "",
|
||||||
store_cors: ''
|
store_cors: "",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const testApp = express()
|
const testApp = express()
|
||||||
|
|
||||||
const container = createContainer()
|
const container = createContainer()
|
||||||
container.register('configModule', asValue(config))
|
|
||||||
|
const featureFlagRouter = featureFlagLoader(config)
|
||||||
|
|
||||||
|
container.register("featureFlagRouter", asValue(featureFlagRouter))
|
||||||
|
container.register("configModule", asValue(config))
|
||||||
container.register({
|
container.register({
|
||||||
logger: asValue({
|
logger: asValue({
|
||||||
error: () => {},
|
error: () => {},
|
||||||
@@ -69,10 +74,16 @@ apiLoader({ container, app: testApp, configModule: config })
|
|||||||
const supertestRequest = supertest(testApp)
|
const supertestRequest = supertest(testApp)
|
||||||
|
|
||||||
export async function request(method, url, opts = {}) {
|
export async function request(method, url, opts = {}) {
|
||||||
const { payload, query, headers = {} } = opts
|
const { payload, query, headers = {}, flags = [] } = opts
|
||||||
|
|
||||||
const queryParams = query && querystring.stringify(query);
|
flags.forEach((flag) => {
|
||||||
const req = supertestRequest[method.toLowerCase()](`${url}${queryParams ? "?" + queryParams : ''}`)
|
featureFlagRouter.setFlag(flag, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
const queryParams = query && querystring.stringify(query)
|
||||||
|
const req = supertestRequest[method.toLowerCase()](
|
||||||
|
`${url}${queryParams ? "?" + queryParams : ""}`
|
||||||
|
)
|
||||||
headers.Cookie = headers.Cookie || ""
|
headers.Cookie = headers.Cookie || ""
|
||||||
if (opts.adminSession) {
|
if (opts.adminSession) {
|
||||||
if (opts.adminSession.jwt) {
|
if (opts.adminSession.jwt) {
|
||||||
|
|||||||
@@ -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",
|
cookie_secret: cookie_secret ?? "supersecret",
|
||||||
...configModule?.projectConfig,
|
...configModule?.projectConfig,
|
||||||
},
|
},
|
||||||
|
featureFlags: configModule?.featureFlags ?? {},
|
||||||
plugins: configModule?.plugins ?? [],
|
plugins: configModule?.plugins ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,7 +1,8 @@
|
|||||||
import loadConfig from './config'
|
import loadConfig from "./config"
|
||||||
import "reflect-metadata"
|
import "reflect-metadata"
|
||||||
import Logger from "./logger"
|
import Logger from "./logger"
|
||||||
import apiLoader from "./api"
|
import apiLoader from "./api"
|
||||||
|
import featureFlagsLoader from "./feature-flags"
|
||||||
import databaseLoader from "./database"
|
import databaseLoader from "./database"
|
||||||
import defaultsLoader from "./defaults"
|
import defaultsLoader from "./defaults"
|
||||||
import expressLoader from "./express"
|
import expressLoader from "./express"
|
||||||
@@ -18,35 +19,50 @@ import subscribersLoader from "./subscribers"
|
|||||||
import { ClassOrFunctionReturning } from "awilix/lib/container"
|
import { ClassOrFunctionReturning } from "awilix/lib/container"
|
||||||
import { Connection, getManager } from "typeorm"
|
import { Connection, getManager } from "typeorm"
|
||||||
import { Express, NextFunction, Request, Response } from "express"
|
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 { track } from "medusa-telemetry"
|
||||||
import { MedusaContainer } from "../types/global"
|
import { MedusaContainer } from "../types/global"
|
||||||
|
|
||||||
type Options = {
|
type Options = {
|
||||||
directory: string;
|
directory: string
|
||||||
expressApp: Express;
|
expressApp: Express
|
||||||
isTest: boolean
|
isTest: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async (
|
export default async ({
|
||||||
{
|
|
||||||
directory: rootDirectory,
|
directory: rootDirectory,
|
||||||
expressApp,
|
expressApp,
|
||||||
isTest
|
isTest,
|
||||||
}: Options
|
}: Options): Promise<{
|
||||||
): Promise<{ container: MedusaContainer; dbConnection: Connection; app: Express }> => {
|
container: MedusaContainer
|
||||||
|
dbConnection: Connection
|
||||||
|
app: Express
|
||||||
|
}> => {
|
||||||
const configModule = loadConfig(rootDirectory)
|
const configModule = loadConfig(rootDirectory)
|
||||||
|
|
||||||
const container = createContainer() as MedusaContainer
|
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"
|
const storeKey = name + "_STORE"
|
||||||
|
|
||||||
if (this.registrations[storeKey] === undefined) {
|
if (this.registrations[storeKey] === undefined) {
|
||||||
this.register(storeKey, asValue([] as Resolver<unknown>[]))
|
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) {
|
if (this.registrations[name] === undefined) {
|
||||||
this.register(name, asArray(store))
|
this.register(name, asArray(store))
|
||||||
@@ -59,15 +75,17 @@ export default async (
|
|||||||
// Add additional information to context of request
|
// Add additional information to context of request
|
||||||
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
const ipAddress = requestIp.getClientIp(req) as string
|
const ipAddress = requestIp.getClientIp(req) as string
|
||||||
|
;(req as any).request_context = {
|
||||||
(req as any).request_context = {
|
|
||||||
ip_address: ipAddress,
|
ip_address: ipAddress,
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const featureFlagRouter = featureFlagsLoader(configModule, Logger)
|
||||||
|
|
||||||
container.register({
|
container.register({
|
||||||
logger: asValue(Logger)
|
logger: asValue(Logger),
|
||||||
|
featureFlagRouter: asValue(featureFlagRouter),
|
||||||
})
|
})
|
||||||
|
|
||||||
await redisLoader({ container, configModule, logger: Logger })
|
await redisLoader({ container, configModule, logger: Logger })
|
||||||
@@ -83,7 +101,7 @@ export default async (
|
|||||||
await registerPluginModels({
|
await registerPluginModels({
|
||||||
rootDirectory,
|
rootDirectory,
|
||||||
container,
|
container,
|
||||||
configModule
|
configModule,
|
||||||
})
|
})
|
||||||
const pmAct = Logger.success(pmActivity, "Plugin models initialized") || {}
|
const pmAct = Logger.success(pmActivity, "Plugin models initialized") || {}
|
||||||
track("PLUGIN_MODELS_INIT_COMPLETED", { duration: pmAct.duration })
|
track("PLUGIN_MODELS_INIT_COMPLETED", { duration: pmAct.duration })
|
||||||
@@ -100,7 +118,7 @@ export default async (
|
|||||||
const dbAct = Logger.success(dbActivity, "Database initialized") || {}
|
const dbAct = Logger.success(dbActivity, "Database initialized") || {}
|
||||||
track("DATABASE_INIT_COMPLETED", { duration: dbAct.duration })
|
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")
|
const stratActivity = Logger.activity("Initializing strategies")
|
||||||
track("STRATEGIES_INIT_STARTED")
|
track("STRATEGIES_INIT_STARTED")
|
||||||
@@ -123,8 +141,8 @@ export default async (
|
|||||||
|
|
||||||
// Add the registered services to the request scope
|
// Add the registered services to the request scope
|
||||||
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
expressApp.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
container.register({ manager: asValue(getManager()) });
|
container.register({ manager: asValue(getManager()) })
|
||||||
(req as any).scope = container.createScope()
|
;(req as any).scope = container.createScope()
|
||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
store_cors?: string
|
||||||
admin_cors?: string
|
admin_cors?: string
|
||||||
}
|
}
|
||||||
|
featureFlags: Record<string, boolean | string>
|
||||||
plugins: (
|
plugins: (
|
||||||
| {
|
| {
|
||||||
resolve: string
|
resolve: string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getConfigFile } from "medusa-core-utils"
|
|
||||||
import path from "path"
|
|
||||||
import { Column, ColumnOptions, ColumnType } from "typeorm"
|
import { Column, ColumnOptions, ColumnType } from "typeorm"
|
||||||
|
import path from "path"
|
||||||
|
import { getConfigFile } from "medusa-core-utils"
|
||||||
|
|
||||||
const pgSqliteTypeMapping: { [key: string]: ColumnType } = {
|
const pgSqliteTypeMapping: { [key: string]: ColumnType } = {
|
||||||
increment: "rowid",
|
increment: "rowid",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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