Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env-cmdrc-template
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"REGEX_MAX_TIMEOUT": 3000,
"REGEX_MAX_BLACKLIST": 50,
"MAX_REQUEST_PER_MINUTE": 0,
"CACHE_SNAPSHOT_MS": 5000,

"SWITCHER_API_LOGGER": true,
"SWITCHER_API_LOGGER_LEVEL": "debug",
Expand Down Expand Up @@ -43,6 +44,7 @@
"REGEX_MAX_TIMEOUT": 3000,
"REGEX_MAX_BLACKLIST": 50,
"MAX_REQUEST_PER_MINUTE": 0,
"CACHE_SNAPSHOT_MS": 5000,

"SWITCHER_API_LOGGER": false,
"SWITCHER_API_LOGGER_LEVEL": "debug",
Expand Down
3 changes: 1 addition & 2 deletions .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
# These are supported funding model platforms

patreon: switcherapi
ko_fi: petruki
github: [petruki]
175 changes: 95 additions & 80 deletions npm-shrinkwrap.json

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,21 +37,21 @@
],
"license": "MIT",
"dependencies": {
"axios": "^1.15.0",
"axios": "^1.16.0",
"bcryptjs": "^3.0.3",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-basic-auth": "^1.2.1",
"express-rate-limit": "^8.3.2",
"express-rate-limit": "^8.4.1",
"express-validator": "^7.3.2",
"graphql": "^16.13.2",
"graphql-http": "^1.22.4",
"graphql-tag": "^2.12.6",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"moment": "^2.30.1",
"mongodb": "^7.1.1",
"mongoose": "^9.4.1",
"mongodb": "^7.2.0",
"mongoose": "^9.6.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"swagger-ui-express": "^5.0.1",
Expand All @@ -60,7 +60,7 @@
},
"devDependencies": {
"env-cmd": "^11.0.0",
"eslint": "^10.2.0",
"eslint": "^10.3.0",
"jest": "^30.3.0",
"jest-sonar-reporter": "^2.0.0",
"node-notifier": "^10.0.1",
Expand Down
16 changes: 11 additions & 5 deletions src/aggregator/configuration-type.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,27 +100,27 @@ export const relayType = new GraphQLObjectType({
}
},
verifiedByEnv: {
type: new GraphQLList(envValue),
type: new GraphQLList(envStatus),
resolve: (source) => {
return resolveEnvValue(source, 'verified', Object.keys(source.verified));
return resolveEnvValue(source, 'verified', Object.keys(source.verified || {}));
}
},
endpointByEnv: {
type: new GraphQLList(envValue),
resolve: (source) => {
return resolveEnvValue(source, 'endpoint', Object.keys(source.endpoint));
return resolveEnvValue(source, 'endpoint', Object.keys(source.endpoint || {}));
}
},
statusByEnv: {
type: new GraphQLList(envStatus),
resolve: (source) => {
return resolveEnvValue(source, 'activated', Object.keys(source.activated));
return resolveEnvValue(source, 'activated', Object.keys(source.activated || {}));
}
},
authTokenByEnv: {
type: new GraphQLList(envValue),
resolve: (source) => {
return resolveEnvValue(source, 'auth_token', Object.keys(source.auth_token));
return resolveEnvValue(source, 'auth_token', Object.keys(source.auth_token || {}));
}
},
authPrefix: {
Expand Down Expand Up @@ -173,6 +173,12 @@ export const configType = new GraphQLObjectType({
resolve: (source, _args, context) => {
return resolveRelay(source, context);
}
},
disableMetricsByEnv: {
type: new GraphQLList(envStatus),
resolve: (source) => {
return resolveEnvValue(source, 'disable_metrics', Object.keys(source.disable_metrics));
}
}
}
});
Expand Down
11 changes: 10 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import mongoose from 'mongoose';
import swaggerDocument from './api-docs/swagger-document.js';
import clientApiRouter from './routers/client-api.js';
import TimedMatch from './helpers/timed-match/index.js';
import Cache from './helpers/cache/index.js';
import schema from './aggregator/schema.js';
import { appAuth, resourcesAuth } from './middleware/auth.js';
import { clientLimiter, defaultLimiter } from './middleware/limiter.js';
Expand All @@ -20,6 +21,13 @@ import { createServer } from './app-server.js';
*/
TimedMatch.initializeWorker();

/**
* Initialize cache
*/
const cache = Cache.getInstance();
await cache.initializeCache();
cache.startScheduledUpdates({ interval: Number.parseInt(process.env.CACHE_SNAPSHOT_MS) });

/**
* Express app instance
*/
Expand Down Expand Up @@ -77,7 +85,8 @@ app.get('/check', defaultLimiter, (req, res) => {
metrics: process.env.METRICS_ACTIVATED,
max_rpm: process.env.MAX_REQUEST_PER_MINUTE,
regex_max_timeout: process.env.REGEX_MAX_TIMEOUT,
regex_max_blacklist: process.env.REGEX_MAX_BLACKLIST
regex_max_blacklist: process.env.REGEX_MAX_BLACKLIST,
cache_snapshot_ms: process.env.CACHE_SNAPSHOT_MS,
};
}

Expand Down
21 changes: 20 additions & 1 deletion src/helpers/cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,34 @@ export default class Cache {
return Cache.instance;
}

isEnabled() {
return process.env.CACHE_SNAPSHOT_MS !== undefined;
}

async initializeCache() {
if (!this.isEnabled()) {
Logger.info('Cache is disabled. Skipping cache initialization.');
return;
}

Logger.info('Cache is enabled. Initializing cache...');
const domains = await getAllDomains();

for (const domain of domains) {
await this.#updateCache(domain);
}
}

async refreshDomain(domainId) {
await this.#updateCache({ _id: domainId });
}

async startScheduledUpdates(options = {}) {
if (!this.isEnabled()) {
Logger.info('Cache is disabled. Skipping scheduled updates.');
return;
}

this.#workerManager = new CacheWorkerManager({
onCacheUpdates: (updates) => this.#handleCacheUpdates(updates),
onCacheDeletions: (deletions) => this.#handleCacheDeletions(deletions),
Expand Down Expand Up @@ -90,7 +109,7 @@ export default class Cache {
}

status() {
return this.#workerManager.getStatus();
return this.#workerManager?.getStatus();
}

get(key) {
Expand Down
31 changes: 23 additions & 8 deletions src/helpers/cache/query.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
export const domainQuery = (id) => `
query {
domain(_id: "${id}") {
_id
version
name
description
statusByEnv { env value }
group {
_id
name
description
statusByEnv { env value }
config {
_id
key
components
statusByEnv { env value }
disableMetricsByEnv { env value }
strategies {
description
strategy
operation
values
Expand All @@ -33,7 +42,7 @@ export const domainQuery = (id) => `
export function reduceSnapshot(snapshot) {
const reduced = { ...snapshot };

reduced.activated = reduced.statusByEnv.reduce((acc, { env, value }) => {
reduced.activated = reduced.statusByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
Expand All @@ -48,7 +57,7 @@ export function reduceSnapshot(snapshot) {
return config;
});

group.activated = group.statusByEnv.reduce((acc, { env, value }) => {
group.activated = group.statusByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
Expand All @@ -61,16 +70,22 @@ export function reduceSnapshot(snapshot) {
}

function reduceConfig(config) {
config.activated = config.statusByEnv.reduce((acc, { env, value }) => {
config.activated = config.statusByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
delete config.statusByEnv;

config.disable_metrics = config.disableMetricsByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
delete config.disableMetricsByEnv;
}

function reduceConfigStrategy(config) {
config.strategies = config.strategies?.map(strategy => {
strategy.activated = strategy.statusByEnv.reduce((acc, { env, value }) => {
strategy.activated = strategy.statusByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
Expand All @@ -88,25 +103,25 @@ function reduceRelay(config) {
config.relay.auth_prefix = config.relay.authPrefix;
delete config.relay.authPrefix;

config.relay.activated = config.relay.statusByEnv.reduce((acc, { env, value }) => {
config.relay.activated = config.relay.statusByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
delete config.relay.statusByEnv;

config.relay.endpoint = config.relay.endpointByEnv.reduce((acc, { env, value }) => {
config.relay.endpoint = config.relay.endpointByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
delete config.relay.endpointByEnv;

config.relay.auth_token = config.relay.authTokenByEnv.reduce((acc, { env, value }) => {
config.relay.auth_token = config.relay.authTokenByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
delete config.relay.authTokenByEnv;

config.relay.verified = config.relay.verifiedByEnv.reduce((acc, { env, value }) => {
config.relay.verified = config.relay.verifiedByEnv?.reduce((acc, { env, value }) => {
acc[env] = value;
return acc;
}, {});
Expand Down
4 changes: 3 additions & 1 deletion src/middleware/validators.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { validationResult } from 'express-validator';
import { getConfig } from '../services/config.js';
import Cache from '../helpers/cache/index.js';

export async function checkConfig(req, res, next) {
const config = await getConfig({ domain: req.domain, key: String(req.query.key) }, true);
Expand All @@ -14,8 +15,9 @@ export async function checkConfig(req, res, next) {
}

export async function checkConfigComponent(req, res, next) {
const cache = Cache.getInstance();
const hasComponent = req.config.components.some((c) =>
c.toString() === req.componentId.toString());
c.toString() === (cache.isEnabled() ? req.component.toString() : req.componentId.toString()));

if (!hasComponent) {
return res.status(401).send({
Expand Down
7 changes: 7 additions & 0 deletions src/services/config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import Cache from '../helpers/cache/index.js';
import { Config } from '../models/config.js';
import { BadRequestError } from '../exceptions/index.js';
import { getConfigFromCache } from './snapshot-cache.js';

export async function getConfig(where, lean = false) {
const cache = Cache.getInstance();
if (cache.isEnabled()) {
return getConfigFromCache(cache, where.domain, where.key);
}

const query = Config.findOne();

query.where('domain', where.domain);
Expand Down
31 changes: 20 additions & 11 deletions src/services/criteria.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Cache from '../helpers/cache/index.js';
import Logger from '../helpers/logger.js';
import { ConfigStrategy, processOperation } from '../models/config-strategy.js';
import { RelayTypes } from '../models/config.js';
Expand All @@ -7,22 +8,30 @@ import GroupConfig from '../models/group-config.js';
import { addMetrics } from '../models/metric.js';
import { isRelayValid, isRelayVerified } from './config.js';
import { resolveNotification, resolveValidation } from './relay.js';
import { findConfigStrategiesInCache, findDomainInCache, findGroupInCache } from './snapshot-cache.js';

export async function evaluateCriteria(config, context, strategyFilter) {
context.config_id = config._id;
const environment = context.environment;
const cache = Cache.getInstance();
let domain, group, strategies;

// Fetch domain, group and strategies in parallel
await Promise.all([
findDomain(context.domain),
findGroup(config),
findConfigStrategies(config._id, context.domain, strategyFilter)
]).then(result => {
domain = result[0];
group = result[1];
strategies = result[2];
});
if (cache.isEnabled()) {
domain = findDomainInCache(cache, context.domain);
group = findGroupInCache(cache, context.domain, config.group);
strategies = findConfigStrategiesInCache(cache, config.key, context.domain, group?._id);
} else {
// Fetch domain, group and strategies in parallel
await Promise.all([
findDomain(context.domain),
findGroup(config),
findConfigStrategies(config._id, context.domain, strategyFilter)
]).then(result => {
domain = result[0];
group = result[1];
strategies = result[2];
});
}

// Prepare response object
const response = {
Expand Down Expand Up @@ -117,7 +126,7 @@ function checkStrategyInput(entry, { strategy, operation, values }, response) {

async function checkRelay(config, environment, entry, response) {
try {
if (config.relay?.activated[environment]) {
if (config.relay?.activated?.[environment]) {
isRelayValid(config.relay);
isRelayVerified(config.relay, environment);

Expand Down
31 changes: 31 additions & 0 deletions src/services/snapshot-cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
export function findDomainInCache(cache, domainId) {
const domainCache = cache.get(domainId.toString());
return domainCache?.data;
}

export function findGroupInCache(cache, domainId, groupId) {
const domainCache = cache.get(domainId.toString());
return domainCache?.data.group.find(group => String(group._id) === String(groupId?.toString()));
}

export function findConfigStrategiesInCache(cache, configKey, domainId, groupId) {
const domainCache = cache.get(domainId.toString());
const groupCache = domainCache?.data.group.find(group => String(group._id) === String(groupId?.toString()));
const configCache = groupCache?.config.find(config => config.key === configKey);
return configCache?.strategies;
}

export function getConfigFromCache(cache, domainId, key) {
const domainCache = cache.get(domainId.toString());
const groups = domainCache?.data.group || [];

for (const group of groups) {
const configFound = group.config.filter(c => c.key === key);
if (configFound.length) {
configFound[0].group = group._id;
return configFound[0];
}
}

return null;
}
Loading
Loading