@stuntman/server
Version:
Stuntman - HTTP proxy / mock server with API
195 lines • 7.61 kB
JavaScript
import { Lock } from 'async-await-mutex-lock';
import { AppError, DEFAULT_RULE_PRIORITY, HttpCode, errorToLog, logger } from '@stuntman/shared';
import { CUSTOM_RULES, DEFAULT_RULES } from './rules/index.js';
const ruleExecutors = {};
const transformMockRuleToLive = (rule) => {
return {
...rule,
counter: 0,
isEnabled: rule.isEnabled ?? true,
createdTimestamp: Date.now(),
};
};
class RuleExecutor {
// TODO persistent rule storage maybe
_rules;
rulesLock = new Lock();
get enabledRules() {
if (!this._rules) {
this._rules = new Array();
}
const now = Date.now();
return this._rules
.filter((r) => r.isEnabled && (!Number.isFinite(r.ttlSeconds) || r.createdTimestamp + r.ttlSeconds * 1000 > now))
.sort((a, b) => (a.priority ?? DEFAULT_RULE_PRIORITY) - (b.priority ?? DEFAULT_RULE_PRIORITY));
}
constructor(rules) {
this._rules = (rules || []).map(transformMockRuleToLive);
}
hasExpired() {
const now = Date.now();
return this._rules.some((r) => Number.isFinite(r.ttlSeconds) && r.createdTimestamp + r.ttlSeconds * 1000 < now);
}
async cleanUpExpired() {
if (!this.hasExpired()) {
return;
}
await this.rulesLock.acquire();
const now = Date.now();
try {
this._rules = this._rules.filter((r) => {
const shouldKeep = !Number.isFinite(r.ttlSeconds) || r.createdTimestamp + r.ttlSeconds * 1000 > now;
if (!shouldKeep) {
logger.debug({ ruleId: r.id }, 'removing expired rule');
}
return shouldKeep;
});
}
finally {
await this.rulesLock.release();
}
}
async addRule(rule, overwrite) {
await this.cleanUpExpired();
await this.rulesLock.acquire();
try {
if (this._rules.some((r) => r.id === rule.id)) {
if (!overwrite) {
throw new AppError({ httpCode: HttpCode.CONFLICT, message: 'rule with given ID already exists' });
}
this._removeRule(rule.id);
}
const liveRule = transformMockRuleToLive(rule);
this._rules.push(liveRule);
logger.debug(liveRule, 'rule added');
return liveRule;
}
finally {
await this.rulesLock.release();
}
}
_removeRule(ruleOrId) {
this._rules = this._rules.filter((r) => {
const notFound = r.id !== (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id);
if (!notFound) {
logger.debug({ ruleId: r.id }, 'rule removed');
}
return notFound;
});
}
async removeRule(ruleOrId) {
await this.cleanUpExpired();
await this.rulesLock.acquire();
try {
this._removeRule(ruleOrId);
}
finally {
await this.rulesLock.release();
}
}
enableRule(ruleOrId) {
const ruleId = typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id;
this._rules.forEach((r) => {
if (r.id === ruleId) {
r.counter = 0;
r.isEnabled = true;
logger.debug({ ruleId: r.id }, 'rule enabled');
}
});
}
disableRule(ruleOrId) {
const ruleId = typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id;
this._rules.forEach((r) => {
if (r.id === ruleId) {
r.isEnabled = false;
logger.debug({ ruleId: r.id }, 'rule disabled');
}
});
}
async findMatchingRule(request) {
const logContext = {
requestId: request.id,
};
let dynamicLabels = [];
const matchingRule = this.enabledRules.find((rule) => {
try {
const matchResult = rule.matches(request);
logger.trace({ ...logContext, matchResult }, `rule match attempt for ${rule.id}`);
if (typeof matchResult === 'boolean') {
return matchResult;
}
if (matchResult.labels) {
dynamicLabels = matchResult.labels;
}
return matchResult.result;
}
catch (error) {
logger.error({ ...logContext, ruleId: rule.id, error: errorToLog(error) }, 'error in rule match function');
}
return undefined;
});
if (!matchingRule) {
logger.debug(logContext, 'no matching rule found');
return null;
}
const matchResult = matchingRule.matches(request);
logContext.ruleId = matchingRule.id;
logger.debug({ ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null }, 'found matching rule');
const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule, {
labels: matchingRule.labels ? [...(matchingRule.labels || []), ...dynamicLabels] : dynamicLabels,
}));
++matchingRule.counter;
logContext.ruleCounter = matchingRule.counter;
if (Number.isNaN(matchingRule.counter) || !Number.isFinite(matchingRule.counter)) {
matchingRule.counter = 0;
logger.warn(logContext, "it's over 9000!!!");
}
if (matchingRule.disableAfterUse) {
if (matchingRule.disableAfterUse === true || matchingRule.disableAfterUse <= matchingRule.counter) {
logger.debug(logContext, 'disabling rule for future requests');
matchingRule.isEnabled = false;
}
}
if (matchingRule.removeAfterUse) {
if (matchingRule.removeAfterUse === true || matchingRule.removeAfterUse <= matchingRule.counter) {
logger.debug(logContext, 'removing rule for future requests');
await this.removeRule(matchingRule);
}
}
if (typeof matchResult !== 'boolean') {
if (matchResult.disableRuleIds && matchResult.disableRuleIds.length > 0) {
logger.debug({ ...logContext, disableRuleIds: matchResult.disableRuleIds }, 'disabling rules based on matchResult');
for (const ruleId of matchResult.disableRuleIds) {
this.disableRule(ruleId);
}
}
if (matchResult.enableRuleIds && matchResult.enableRuleIds.length > 0) {
logger.debug({ ...logContext, disableRuleIds: matchResult.enableRuleIds }, 'enabling rules based on matchResult');
for (const ruleId of matchResult.enableRuleIds) {
this.enableRule(ruleId);
}
}
}
return matchingRuleClone;
}
async getRules() {
await this.cleanUpExpired();
return this._rules;
}
async getRule(id) {
await this.cleanUpExpired();
return this._rules.find((r) => r.id === id);
}
}
export const getRuleExecutor = (mockUuid, overrideRules) => {
if (!ruleExecutors[mockUuid]) {
if (overrideRules === null) {
ruleExecutors[mockUuid] = new RuleExecutor();
}
else {
ruleExecutors[mockUuid] = new RuleExecutor((overrideRules ?? [...DEFAULT_RULES, ...CUSTOM_RULES]).map((r) => ({ ...r, ttlSeconds: Infinity })));
}
}
return ruleExecutors[mockUuid];
};
//# sourceMappingURL=ruleExecutor.js.map