@nestjs/core
Version:
Nest - modern, fast, powerful node.js web framework (@core)
277 lines (276 loc) • 13.3 kB
JavaScript
import { RequestMethod, VERSION_NEUTRAL, VersioningType, } from '@nestjs/common';
import { RouteConflictException } from '../errors/exceptions/route-conflict.exception.js';
import { DUPLICATE_ROUTE_MESSAGE, SHADOWED_ROUTE_MESSAGE, } from '../errors/messages.js';
/**
* Static utility class that detects overlapping HTTP routes and reports
* them according to a per-kind policy. Stateless — every method takes
* everything it needs as parameters.
*/
export class RouteConflictDetector {
/**
* Strips the leading `:` / `*` marker (if present) and tags each
* segment as a literal, named param, or named wildcard. Supports both
* bare named wildcards (`*path`) and adapter-normalized path-to-regexp
* wildcard groups (`{*path}`).
*/
static tokenizePath(rawPath) {
const segments = [];
rawPath
.split('/')
.filter(rawSegment => rawSegment.length > 0)
.forEach(rawSegment => {
if (rawSegment.startsWith('*')) {
segments.push({ kind: 'wildcard', value: rawSegment.slice(1) });
return;
}
if (rawSegment.startsWith('{*') && rawSegment.endsWith('}')) {
segments.push({
kind: 'wildcard',
value: rawSegment.slice(2, -1),
});
return;
}
if (rawSegment.startsWith(':')) {
segments.push({ kind: 'param', value: rawSegment.slice(1) });
return;
}
segments.push({ kind: 'literal', value: rawSegment });
});
return segments;
}
/**
* Decides whether two paths can match the same incoming request, given
* only their declared patterns (no host/method/version considered).
*/
static pathsCanOverlap(leftPath, rightPath) {
const leftSegments = RouteConflictDetector.tokenizePath(leftPath);
const rightSegments = RouteConflictDetector.tokenizePath(rightPath);
const leftEndsInWildcard = leftSegments[leftSegments.length - 1]?.kind === 'wildcard';
const rightEndsInWildcard = rightSegments[rightSegments.length - 1]?.kind === 'wildcard';
// A named wildcard like `*path` requires at least one matched segment,
// so only the *shorter* side's trailing wildcard can absorb the
// difference. If the longer side has the wildcard, the other side
// simply does not have enough segments to ever reach that position.
if (leftSegments.length !== rightSegments.length) {
const shorterEndsInWildcard = leftSegments.length < rightSegments.length
? leftEndsInWildcard
: rightEndsInWildcard;
if (!shorterEndsInWildcard) {
return false;
}
}
const sharedLength = Math.min(leftSegments.length, rightSegments.length);
let canOverlap = true;
leftSegments.slice(0, sharedLength).forEach((leftSegment, segmentIndex) => {
if (!canOverlap)
return;
if (!RouteConflictDetector.segmentsCanOverlap(leftSegment, rightSegments[segmentIndex])) {
canOverlap = false;
}
});
return canOverlap;
}
/**
* Walks every unique pair of resolved routes and produces a conflict
* record for each pair whose (method, host, version, path) tuples can
* collide at runtime.
*/
static detect(routes, versioningOptions) {
const conflicts = [];
RouteConflictDetector.forEachUniquePair(routes, (earlierRoute, laterRoute) => {
if (!RouteConflictDetector.methodsCanOverlap(earlierRoute.method, laterRoute.method)) {
return;
}
if (!RouteConflictDetector.versionsCanOverlap(earlierRoute.version, laterRoute.version, versioningOptions)) {
return;
}
if (!RouteConflictDetector.hostsCanOverlap(earlierRoute.host, laterRoute.host)) {
return;
}
if (!RouteConflictDetector.pathsCanOverlap(earlierRoute.path, laterRoute.path)) {
return;
}
const isIdentical = RouteConflictDetector.routesAreIdentical(earlierRoute, laterRoute, versioningOptions);
conflicts.push({
winner: earlierRoute,
shadowed: laterRoute,
kind: isIdentical ? 'duplicate' : 'shadow',
});
});
return conflicts;
}
/**
* Applies the per-kind policy to a set of conflicts: silences `'off'`,
* logs `'warn'` once per conflict, and aggregates every `'error'`-level
* conflict into a single `RouteConflictException`.
*/
static handle(conflicts, policy, logger) {
if (conflicts.length === 0 || policy === undefined)
return;
const errorMessages = [];
conflicts.forEach(conflict => {
const policyForKind = policy[conflict.kind] ?? 'off';
if (policyForKind === 'off')
return;
const message = RouteConflictDetector.describeConflict(conflict);
if (policyForKind === 'warn') {
logger.warn(message);
return;
}
errorMessages.push(message);
});
if (errorMessages.length > 0) {
throw new RouteConflictException(errorMessages);
}
}
/**
* Removes shadow conflicts that specificity sorting has already resolved.
*
* When `routeResolutionStrategy: 'specificity'` is active, the sort
* promotes more-specific routes ahead of less-specific ones. A shadow
* where the sort promoted the winner (it was declared *later* but sorted
* *first*) is handled correctly at runtime — the more-specific route is
* registered first and handles its requests while the less-specific route
* handles the rest. Retaining such a conflict would cause `shadow: 'error'`
* to abort an application whose routes actually work as intended.
*
* Shadows where the winner was already first in declaration order (the
* sort did not swap them) are genuine and are kept unchanged. Duplicate
* conflicts are always kept.
*
* @param conflicts Conflicts detected on the sorted route list.
* @param declarationOrder Routes in their original declaration order
* (i.e. before specificity sorting was applied).
*/
static filterSortResolvedShadows(conflicts, declarationOrder) {
const declarationIndex = new Map(declarationOrder.map((route, idx) => [route, idx]));
return conflicts.filter(conflict => {
if (conflict.kind !== 'shadow')
return true;
const winnerDeclIdx = declarationIndex.get(conflict.winner) ?? -1;
const shadowedDeclIdx = declarationIndex.get(conflict.shadowed) ?? -1;
// The sort promoted the winner (declared later, but sorted to the
// front because it is more specific). The shadow is resolved at
// runtime — drop it. Keep only genuine shadows where the winner was
// already first in declaration order.
return winnerDeclIdx < shadowedDeclIdx;
});
}
static segmentsCanOverlap(leftSegment, rightSegment) {
if (leftSegment.kind === 'wildcard' || rightSegment.kind === 'wildcard') {
return true;
}
if (leftSegment.kind === 'param' || rightSegment.kind === 'param') {
return true;
}
return leftSegment.value === rightSegment.value;
}
static methodsCanOverlap(leftMethod, rightMethod) {
if (leftMethod === RequestMethod.ALL || rightMethod === RequestMethod.ALL) {
return true;
}
return leftMethod === rightMethod;
}
static versionsCanOverlap(leftVersion, rightVersion, versioningOptions) {
if (!versioningOptions)
return true;
if (versioningOptions.type === VersioningType.URI)
return true;
const leftMatchesAnyVersion = leftVersion === undefined || leftVersion === VERSION_NEUTRAL;
const rightMatchesAnyVersion = rightVersion === undefined || rightVersion === VERSION_NEUTRAL;
if (leftMatchesAnyVersion || rightMatchesAnyVersion)
return true;
const leftValues = Array.isArray(leftVersion) ? leftVersion : [leftVersion];
const rightValues = Array.isArray(rightVersion)
? rightVersion
: [rightVersion];
return leftValues.some(versionValue => rightValues.includes(versionValue));
}
static hostsCanOverlap(leftHost, rightHost) {
if (leftHost === undefined || rightHost === undefined)
return true;
const leftHosts = Array.isArray(leftHost) ? leftHost : [leftHost];
const rightHosts = Array.isArray(rightHost) ? rightHost : [rightHost];
return leftHosts.some(leftValue => rightHosts.some(rightValue => RouteConflictDetector.hostValuesCanMatchSameRequest(leftValue, rightValue)));
}
static hostValuesCanMatchSameRequest(leftValue, rightValue) {
const leftIsRegExp = leftValue instanceof RegExp;
const rightIsRegExp = rightValue instanceof RegExp;
if (leftIsRegExp && rightIsRegExp)
return true;
// Reset lastIndex before calling test() to guard against RegExps with the
// `g` or `y` flags: those are stateful and would produce inconsistent
// results (false negatives) when the same instance is reused across the
// multiple pair comparisons that a single detect() run performs.
if (leftIsRegExp) {
leftValue.lastIndex = 0;
return leftValue.test(rightValue);
}
if (rightIsRegExp) {
rightValue.lastIndex = 0;
return rightValue.test(leftValue);
}
return leftValue === rightValue;
}
static routesAreIdentical(leftRoute, rightRoute, versioningOptions) {
return (leftRoute.method === rightRoute.method &&
leftRoute.path === rightRoute.path &&
RouteConflictDetector.hostsAreIdentical(leftRoute.host, rightRoute.host) &&
RouteConflictDetector.versionsAreIdentical(leftRoute.version, rightRoute.version, versioningOptions));
}
static hostsAreIdentical(leftHost, rightHost) {
if (leftHost === undefined && rightHost === undefined)
return true;
if (leftHost === undefined || rightHost === undefined)
return false;
const leftHosts = Array.isArray(leftHost) ? leftHost : [leftHost];
const rightHosts = Array.isArray(rightHost) ? rightHost : [rightHost];
if (leftHosts.length !== rightHosts.length)
return false;
// Order-insensitive set comparison: ['a', 'b'] and ['b', 'a']
// describe the same allowed-host set, so they are identical for
// duplicate-classification purposes.
return leftHosts.every(leftValue => rightHosts.some(rightValue => RouteConflictDetector.hostValuesAreIdentical(leftValue, rightValue)));
}
static hostValuesAreIdentical(leftValue, rightValue) {
if (leftValue instanceof RegExp && rightValue instanceof RegExp) {
return (leftValue.source === rightValue.source &&
leftValue.flags === rightValue.flags);
}
return leftValue === rightValue;
}
static versionsAreIdentical(leftVersion, rightVersion, versioningOptions) {
// When versioning is not configured (or URI-based, where the
// version is encoded in the path), version metadata does not
// gate request matching at runtime, so two routes that differ
// only in their declared `version` are runtime duplicates.
if (!versioningOptions || versioningOptions.type === VersioningType.URI) {
return true;
}
if (leftVersion === rightVersion)
return true;
const leftValues = Array.isArray(leftVersion) ? leftVersion : [leftVersion];
const rightValues = Array.isArray(rightVersion)
? rightVersion
: [rightVersion];
if (leftValues.length !== rightValues.length)
return false;
return leftValues.every(value => rightValues.includes(value));
}
static forEachUniquePair(items, visit) {
items.forEach((leftItem, leftIndex) => {
items.slice(leftIndex + 1).forEach(rightItem => {
visit(leftItem, rightItem);
});
});
}
static describeConflict(conflict) {
const method = RequestMethod[conflict.winner.method];
const winnerLabel = `${conflict.winner.instanceWrapper.name}#${conflict.winner.methodName}`;
const shadowedLabel = `${conflict.shadowed.instanceWrapper.name}#${conflict.shadowed.methodName}`;
if (conflict.kind === 'duplicate') {
return DUPLICATE_ROUTE_MESSAGE(method, conflict.winner.path, winnerLabel, shadowedLabel);
}
return SHADOWED_ROUTE_MESSAGE(method, conflict.shadowed.path, shadowedLabel, conflict.winner.path, winnerLabel);
}
}