From 41681b45b1962b241b54833aba603ee839322cba Mon Sep 17 00:00:00 2001 From: Philip Korsholm <88927411+pKorsholm@users.noreply.github.com> Date: Mon, 4 Jul 2022 15:39:30 +0200 Subject: [PATCH] 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 --- .../api/__tests__/batch-jobs/api.js | 13 +- integration-tests/helpers/setup-server.js | 3 +- .../helpers/start-server-with-environment.js | 28 ++++ integration-tests/helpers/use-db.js | 53 ++++++- integration-tests/helpers/use-template-db.js | 24 +++- .../api/middlewares/feature-flag-enabled.ts | 18 +++ packages/medusa/src/commands/migrate.js | 11 +- packages/medusa/src/commands/seed.js | 17 ++- .../src/commands/utils/get-migrations.js | 30 +++- packages/medusa/src/helpers/test-request.js | 29 ++-- .../loaders/__tests__/feature-flags.spec.ts | 130 ++++++++++++++++++ packages/medusa/src/loaders/config.ts | 1 + .../medusa/src/loaders/feature-flags/index.ts | 68 +++++++++ packages/medusa/src/loaders/index.ts | 62 ++++++--- packages/medusa/src/types/feature-flags.ts | 10 ++ packages/medusa/src/types/global.ts | 1 + packages/medusa/src/utils/db-aware-column.ts | 4 +- .../src/utils/feature-flag-decorators.ts | 66 +++++++++ packages/medusa/src/utils/flag-router.ts | 17 +++ 19 files changed, 527 insertions(+), 58 deletions(-) create mode 100644 integration-tests/helpers/start-server-with-environment.js create mode 100644 packages/medusa/src/api/middlewares/feature-flag-enabled.ts create mode 100644 packages/medusa/src/loaders/__tests__/feature-flags.spec.ts create mode 100644 packages/medusa/src/loaders/feature-flags/index.ts create mode 100644 packages/medusa/src/types/feature-flags.ts create mode 100644 packages/medusa/src/utils/feature-flag-decorators.ts create mode 100644 packages/medusa/src/utils/flag-router.ts diff --git a/integration-tests/api/__tests__/batch-jobs/api.js b/integration-tests/api/__tests__/batch-jobs/api.js index dafe2985a8..dbf350009f 100644 --- a/integration-tests/api/__tests__/batch-jobs/api.js +++ b/integration-tests/api/__tests__/batch-jobs/api.js @@ -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) diff --git a/integration-tests/helpers/setup-server.js b/integration-tests/helpers/setup-server.js index af986671e3..3e9f2cf232 100644 --- a/integration-tests/helpers/setup-server.js +++ b/integration-tests/helpers/setup-server.js @@ -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"] diff --git a/integration-tests/helpers/start-server-with-environment.js b/integration-tests/helpers/start-server-with-environment.js new file mode 100644 index 0000000000..cef895fb74 --- /dev/null +++ b/integration-tests/helpers/start-server-with-environment.js @@ -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 diff --git a/integration-tests/helpers/use-db.js b/integration-tests/helpers/use-db.js index 5fb1d64c58..36b301c33b 100644 --- a/integration-tests/helpers/use-db.js +++ b/integration-tests/helpers/use-db.js @@ -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 } diff --git a/integration-tests/helpers/use-template-db.js b/integration-tests/helpers/use-template-db.js index e5c77a2b6d..484acda457 100644 --- a/integration-tests/helpers/use-template-db.js +++ b/integration-tests/helpers/use-template-db.js @@ -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() diff --git a/packages/medusa/src/api/middlewares/feature-flag-enabled.ts b/packages/medusa/src/api/middlewares/feature-flag-enabled.ts new file mode 100644 index 0000000000..c2eeb54452 --- /dev/null +++ b/packages/medusa/src/api/middlewares/feature-flag-enabled.ts @@ -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 { + 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() + } + } +} diff --git a/packages/medusa/src/commands/migrate.js b/packages/medusa/src/commands/migrate.js index ef51e52048..6800571223 100644 --- a/packages/medusa/src/commands/migrate.js +++ b/packages/medusa/src/commands/migrate.js @@ -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, }) diff --git a/packages/medusa/src/commands/seed.js b/packages/medusa/src/commands/seed.js index 2d2c329e83..1a372f0e35 100644 --- a/packages/medusa/src/commands/seed.js +++ b/packages/medusa/src/commands/seed.js @@ -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) { diff --git a/packages/medusa/src/commands/utils/get-migrations.js b/packages/medusa/src/commands/utils/get-migrations.js index ba5d6b0a3f..eb4afb91f7 100644 --- a/packages/medusa/src/commands/utils/get-migrations.js +++ b/packages/medusa/src/commands/utils/get-migrations.js @@ -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) } diff --git a/packages/medusa/src/helpers/test-request.js b/packages/medusa/src/helpers/test-request.js index de28d6eec0..f2ede4eb30 100644 --- a/packages/medusa/src/helpers/test-request.js +++ b/packages/medusa/src/helpers/test-request.js @@ -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) { diff --git a/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts b/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts new file mode 100644 index 0000000000..32adee8b6e --- /dev/null +++ b/packages/medusa/src/loaders/__tests__/feature-flags.spec.ts @@ -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) + }) +}) diff --git a/packages/medusa/src/loaders/config.ts b/packages/medusa/src/loaders/config.ts index 6302f604fd..13c8be0ada 100644 --- a/packages/medusa/src/loaders/config.ts +++ b/packages/medusa/src/loaders/config.ts @@ -56,6 +56,7 @@ export default (rootDirectory: string): ConfigModule => { cookie_secret: cookie_secret ?? "supersecret", ...configModule?.projectConfig, }, + featureFlags: configModule?.featureFlags ?? {}, plugins: configModule?.plugins ?? [], } } diff --git a/packages/medusa/src/loaders/feature-flags/index.ts b/packages/medusa/src/loaders/feature-flags/index.ts new file mode 100644 index 0000000000..33844084a4 --- /dev/null +++ b/packages/medusa/src/loaders/feature-flags/index.ts @@ -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 } = {}, + 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 = {} + 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) +} diff --git a/packages/medusa/src/loaders/index.ts b/packages/medusa/src/loaders/index.ts index cdfeaf4b95..7c67e47b1b 100644 --- a/packages/medusa/src/loaders/index.ts +++ b/packages/medusa/src/loaders/index.ts @@ -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[])) } - const store = this.resolve(storeKey) as (ClassOrFunctionReturning | Resolver)[] + const store = this.resolve(storeKey) as ( + | ClassOrFunctionReturning + | Resolver + )[] 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() }) diff --git a/packages/medusa/src/types/feature-flags.ts b/packages/medusa/src/types/feature-flags.ts new file mode 100644 index 0000000000..e29acaffb2 --- /dev/null +++ b/packages/medusa/src/types/feature-flags.ts @@ -0,0 +1,10 @@ +export interface IFlagRouter { + isFeatureEnabled: (key: string) => boolean +} + +export type FlagSettings = { + key: string + description: string + env_key: string + default_val: boolean +} diff --git a/packages/medusa/src/types/global.ts b/packages/medusa/src/types/global.ts index bb91cf9921..5b55dde52d 100644 --- a/packages/medusa/src/types/global.ts +++ b/packages/medusa/src/types/global.ts @@ -52,6 +52,7 @@ export type ConfigModule = { store_cors?: string admin_cors?: string } + featureFlags: Record plugins: ( | { resolve: string diff --git a/packages/medusa/src/utils/db-aware-column.ts b/packages/medusa/src/utils/db-aware-column.ts index 6f46899d18..993e359061 100644 --- a/packages/medusa/src/utils/db-aware-column.ts +++ b/packages/medusa/src/utils/db-aware-column.ts @@ -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", diff --git a/packages/medusa/src/utils/feature-flag-decorators.ts b/packages/medusa/src/utils/feature-flag-decorators.ts new file mode 100644 index 0000000000..0e634634cc --- /dev/null +++ b/packages/medusa/src/utils/feature-flag-decorators.ts @@ -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) +} diff --git a/packages/medusa/src/utils/flag-router.ts b/packages/medusa/src/utils/flag-router.ts new file mode 100644 index 0000000000..8ef8d8ea62 --- /dev/null +++ b/packages/medusa/src/utils/flag-router.ts @@ -0,0 +1,17 @@ +import { IFlagRouter } from "../types/feature-flags" + +export class FlagRouter implements IFlagRouter { + private flags: Record = {} + + constructor(flags: Record) { + this.flags = flags + } + + public isFeatureEnabled(key: string): boolean { + return !!this.flags[key] + } + + public setFlag(key: string, value = true): void { + this.flags[key] = value + } +}