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
93 changes: 93 additions & 0 deletions Sources/HTTPMock/Internal/HTTPMockMatcher+ExpressionStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import Foundation

extension HTTPMockMatcher {
/// Shared cache for compiled wildcard regular expressions.
/// Compiles lazily on first use and reuses across instances and namespaces.
class ExpressionStorage {

// MARK: - Internal properties

var hostExpressions = [String: NSRegularExpression]()
var pathExpressions = [String: NSRegularExpression]()

// MARK: - Private properties

private let lock = NSLock()

// MARK: - Init

init() {}

// MARK: - Internal methods

/// Returns (and caches) a compiled `NSRegularExpression` for the given glob pattern.
///
/// For path patterns, the `/**/` segment is treated as optional to allow matching zero segments.
/// This means `/api/**/users` will match both `/api/users` and `/api/x/users`.
/// - Parameters:
/// - pattern: The glob pattern to compile.
/// - kind: The URL component kind to create regex for.
/// - Throws: Any compilation error from `NSRegularExpression` if the pattern is invalid.
func regex(for pattern: String, kind: MatchKind) throws -> NSRegularExpression {
lock.lock()
defer { lock.unlock() }

// Check if we have a cached regex already
switch kind {
case .host:
if let cached = hostExpressions[pattern] {
return cached
}
case .path:
if let cached = pathExpressions[pattern] {
return cached
}
}

let compiled = try compile(pattern: pattern, kind: kind)

switch kind {
case .host: hostExpressions[pattern] = compiled
case .path: pathExpressions[pattern] = compiled
}

return compiled
}

// MARK: - Private methods

/// Compiles a glob pattern into a regular expression.
/// `*` matches a single segment, `**` matches across segments.
///
/// For path patterns, `/**/` is made optional to allow zero segments between slashes.
/// - Parameters:
/// - pattern: The glob pattern.
/// - kind: The URL component kind to create regex for.
/// - Returns: A compiled regular expression anchored to the start and end of the string.
private func compile(pattern: String, kind: MatchKind) throws -> NSRegularExpression {
// Escape regex meta characters first.
var escaped = NSRegularExpression.escapedPattern(for: pattern)

if case .path = kind {
// Make "/**/" optional so `**` can match zero segments between slashes.
// Example: `/api/**/users` should match both `/api/users` and `/api/v1/users`.
escaped = escaped.replacingOccurrences(of: "\\/\\*\\*\\/", with: "(?:/.*)?")

// If the pattern starts with `**/`, make the leading prefix optional.
escaped = escaped.replacingOccurrences(of: "\\*\\*\\/", with: "(?:.*/)?")

// If the pattern ends with `/**`, make the trailing suffix optional.
escaped = escaped.replacingOccurrences(of: "\\/\\*\\*", with: "(?:/.*)?")
}

// Replace glob tokens. Order matters: handle `**` before `*`.
escaped = escaped.replacingOccurrences(of: "\\*\\*", with: ".*")
escaped = escaped.replacingOccurrences(of: "\\*", with: kind.singleSegmentClass)

return try NSRegularExpression(
pattern: "^" + escaped + "$",
options: kind.regexOptions
)
}
}
}
22 changes: 22 additions & 0 deletions Sources/HTTPMock/Internal/HTTPMockMatcher+MatchKind.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

extension HTTPMockMatcher {
enum MatchKind {
case host
case path

var singleSegmentClass: String {
switch self {
case .host: "[^.]*" // Segment delimiter is `.`
case .path: "[^/]*" // Segment delimiter is `/`
}
}

var regexOptions: NSRegularExpression.Options {
switch self {
case .host: [.caseInsensitive]
case .path: []
}
}
}
}
124 changes: 124 additions & 0 deletions Sources/HTTPMock/Internal/HTTPMockMatcher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation

/// Responsible for matching incoming requests against registered mock keys.
/// Supports exact matches and wildcard patterns (`*` for a single segment, `**` for multiple segments (zero or more)).
/// Also compares query parameters and applies query matching rules (`.exact` / `.contains`).
struct HTTPMockMatcher {

// MARK: - Internal properties

let expressionStorage = ExpressionStorage()

// MARK: - Internal methods

/// Finds the most specific key that matches the given request values.
/// - Parameters:
/// - host: The request host string (lowercased where applicable).
/// - path: The request path string (leading "/" guaranteed).
/// - queryItems: Dictionary of query params from the request.
/// - candidates: The set of registered keys to match against (within a namespace).
/// - Returns: A matching key if found, preferring exact matches first and then the most specific wildcard pattern.
func match(
host: String,
path: String,
queryItems: [String: String],
in candidates: Set<HTTPMockURLProtocol.Key>
) -> HTTPMockURLProtocol.Key? {
let exactMatch = candidates.first { candidate in
candidate.host == host &&
candidate.path == path &&
queryMatches(candidate, requestQueryItems: queryItems)
}

// Return early if we have an exact key match
if let exactMatch { return exactMatch }

// Find wildcard candidates
let wildcardCandidates = candidates.filter { candidate in
queryMatches(candidate, requestQueryItems: queryItems) &&
wildcardMatch(pattern: candidate.host, value: host, kind: .host) &&
wildcardMatch(pattern: candidate.path, value: path, kind: .path)
}

// Prefer the most specific candidate, aka. fewest wildcards, then longest literal length
return wildcardCandidates
.map { (key: $0, score: specificityScore(for: $0)) }
.sorted { $0.score < $1.score }
.first?
.key
}

/// Checks whether a candidate key's query requirements match the request's query items.
/// - Parameters:
/// - candidate: The stored key we compare against.
/// - requestQueryItems: The request's query items as a dictionary.
/// - Returns: `true` if the request query satisfies the candidate's rule, otherwise `false`.
func queryMatches(
_ candidate: HTTPMockURLProtocol.Key,
requestQueryItems: [String: String]
) -> Bool {
guard let expected = candidate.queryItems else {
return true
}

switch candidate.queryMatching {
case .exact:
return expected == requestQueryItems
case .contains:
return expected.allSatisfy { key, value in
requestQueryItems[key] == value
}
}
}

/// Matches a concrete string value against a glob pattern.
/// - Parameters:
/// - pattern: A literal or glob string (`*` single segment, `**` multi segment (zero or more)).
/// - value: The actual host or path string to test.
/// - kind: The URL component kind to compare against.
/// - Returns: `true` if the value matches the pattern, otherwise `false`.
func wildcardMatch(
pattern: String,
value: String,
kind: MatchKind
) -> Bool {
// If pattern doesn't include any wildcards we just check if the strings match.
if !pattern.contains("*") {
return pattern == value
}

// Check if we have a cached pattern already, or create one if not.
// If regex compilation fails: fail silently and return `false`.
guard let regularExpression = try? expressionStorage.regex(for: pattern, kind: kind) else {
return false
}

let searchRange = NSRange(value.startIndex..<value.endIndex, in: value)
return regularExpression.firstMatch(in: value, range: searchRange) != nil
}

/// Computes a specificity score for a key where the return is a tuple of `(wildcardCount, -literalCount)`.
/// Swift compares tuples lexicographically, so sorting by this tuple prefers keys with fewer wildcards first,
/// and if tied, prefers those with more literal characters (longer, more specific patterns).
/// Lower scores are considered more specific.
///
/// This method is used to determine which wildcard pattern is the "most specific" when multiple patterns could match.
/// It prefers patterns with fewer wildcards and then those with more literal characters to ensure the best match is selected.
func specificityScore(for key: HTTPMockURLProtocol.Key) -> (Int, Int) {
func score(for pattern: String) -> (wildcardCount: Int, literalCount: Int) {
let wildcardCount = pattern.filter { $0 == "*" }.count
let literalCount = pattern.replacingOccurrences(of: "*", with: "").count
return (wildcardCount, literalCount)
}

let hostScore = score(for: key.host)
let pathScore = score(for: key.path)

// Fewer wildcards first. If equal: prefer longer literals.
// Returns tuple: (wildcardCount, -literalCount) for lexicographical comparison.
let wildcardScore = hostScore.wildcardCount + pathScore.wildcardCount
let literalScore = hostScore.literalCount + pathScore.literalCount

return (wildcardScore, -literalScore)
}
}
7 changes: 7 additions & 0 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol+Key.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,12 @@ extension HTTPMockURLProtocol {
let path: String
let queryItems: [String: String]?
let queryMatching: QueryMatching

init(host: String, path: String, queryItems: [String : String]?, queryMatching: QueryMatching) {
self.host = host.lowercased()
self.path = path
self.queryItems = queryItems
self.queryMatching = queryMatching
}
}
}
8 changes: 8 additions & 0 deletions Sources/HTTPMock/Internal/HTTPMockURLProtocol+MockMatch.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

extension HTTPMockURLProtocol {
struct MockMatch {
let key: Key
let response: MockResponse
}
}
Loading