UNPKG

@nestjs/core

Version:

Nest - modern, fast, powerful node.js web framework (@core)

277 lines (276 loc) 13.3 kB
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); } }