Skip to content

Commit 3cfb1e9

Browse files
Merge pull request #3 from modestman/feature/pattern-matching
Pattern matching
2 parents 54a5a2a + 4f2aa2c commit 3cfb1e9

17 files changed

+651
-42
lines changed

README.md

+35
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,41 @@ final class LoginUITests: XCTestCase {
143143
}
144144
```
145145

146+
### Request patterns
147+
148+
You can specify a pattern for catch http requests and make a response with mock data. Pattern matching applied for URL and http headers in the request. See `RequestPattern` struct.
149+
150+
Three types of patterns can be used:
151+
152+
- `equal` - the request value must be exactly the same as the pattern value,
153+
- `wildcard` - the request value match with the wildcard pattern (see below),
154+
- `regexp` - the request value match with the regular expression pattern.
155+
156+
##### Note:
157+
If you want to apply a wildcard pattern for the url query parameters, don't forget escape `?` symbol after domain or path.
158+
159+
```swift
160+
Pattern.wildcard("http://example.com\?query=*")
161+
```
162+
163+
### Wildcard pattern
164+
165+
"Wildcards" are the patterns you type when you do stuff like `ls *.js` on the command line, or put `build/*` in a `.gitignore` file.
166+
167+
In our implementation any wildcard pattern translates to regular expression and applies matching with URL or header string.
168+
169+
The following characters have special magic meaning when used in a pattern:
170+
171+
- `*` matches 0 or more characters in a single path portion
172+
- `?` matches 1 character
173+
- `[a-z]` matches a range of characters, similar to a RegExp range.
174+
- `{bar,baz}` matches one of the substitution listed in braces. For example pattern `foo{bar,baz}` matches strings `foobar` or `foobaz`
175+
176+
You can escape special characters with backslash `\`.
177+
178+
Negation in groups is not supported.
179+
180+
146181
## Example project
147182

148183
```bash

Sources/CatbirdAPI/Pattern.swift

+64
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Foundation
2+
3+
/// The kind of pattern for matching request fields such as url and headers
4+
public struct Pattern: Codable, Hashable {
5+
6+
// MARK: - Public types
7+
8+
/// - equal: The request value must be equal to the pattern value
9+
/// - wildcard: The request value match with the wildcard pattern
10+
/// - regexp: The request value match with the regular expression pattern
11+
public enum Kind: String, Codable {
12+
case equal, wildcard, regexp
13+
}
14+
15+
16+
// MARK: - Public properties
17+
18+
public let kind: Kind
19+
public let value: String
20+
21+
22+
// MARK: - Init
23+
24+
public init(kind: Kind, value: String) {
25+
self.kind = kind
26+
self.value = value
27+
}
28+
29+
public static func equal(_ value: String) -> Pattern {
30+
return Pattern(kind: .equal, value: value)
31+
}
32+
33+
public static func wildcard(_ value: String) -> Pattern {
34+
return Pattern(kind: .wildcard, value: value)
35+
}
36+
37+
public static func regexp(_ value: String) -> Pattern {
38+
return Pattern(kind: .regexp, value: value)
39+
}
40+
}
41+
42+
43+
/// Protocol for converting common types to Pattern
44+
public protocol PatternRepresentable {
45+
var pattern: Pattern { get }
46+
}
47+
48+
extension Pattern: PatternRepresentable {
49+
public var pattern: Pattern {
50+
return self
51+
}
52+
}
53+
54+
extension String: PatternRepresentable {
55+
public var pattern: Pattern {
56+
return .equal(self)
57+
}
58+
}
59+
60+
extension URL: PatternRepresentable {
61+
public var pattern: Pattern {
62+
return .equal(self.absoluteString)
63+
}
64+
}

Sources/CatbirdAPI/RequestPattern.swift

+13-12
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,54 @@ import Foundation
66
public struct RequestPattern: Codable, Hashable {
77

88
public func hash(into hasher: inout Hasher) {
9-
hasher.combine(url.absoluteString)
9+
hasher.combine(url.value)
1010
hasher.combine(method)
11+
hasher.combine(headerFields)
1112
}
1213

1314
/// HTTP method.
1415
public let method: String
1516

1617
/// Request URL.
17-
public let url: URL
18+
public let url: Pattern
1819

1920
/// Request required headers. Default empty.
20-
public let headerFields: [String: String]
21+
public let headerFields: [String: Pattern]
2122

2223
/// A new request pattern.
2324
///
2425
/// - Parameters:
2526
/// - method: HTTP method.
26-
/// - url: Request URL.
27+
/// - url: Request URL or pattern.
2728
/// - headerFields: Request required headers. Default empty.
28-
public init(method: String, url: URL, headerFields: [String: String] = [:]) {
29+
public init(method: String, url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) {
2930
self.method = method
30-
self.url = url
31-
self.headerFields = headerFields
31+
self.url = url.pattern
32+
self.headerFields = headerFields.mapValues { $0.pattern }
3233
}
3334

3435
/// A new pattern for `GET` request.
35-
public static func get(_ url: URL, headerFields: [String: String] = [:]) -> RequestPattern {
36+
public static func get(_ url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) -> RequestPattern {
3637
return RequestPattern(method: "GET", url: url, headerFields: headerFields)
3738
}
3839

3940
/// A new pattern for `POST` request.
40-
public static func post(_ url: URL, headerFields: [String: String] = [:]) -> RequestPattern {
41+
public static func post(_ url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) -> RequestPattern {
4142
return RequestPattern(method: "POST", url: url, headerFields: headerFields)
4243
}
4344

4445
/// A new pattern for `PUT` request.
45-
public static func put(_ url: URL, headerFields: [String: String] = [:]) -> RequestPattern {
46+
public static func put(_ url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) -> RequestPattern {
4647
return RequestPattern(method: "PUT", url: url, headerFields: headerFields)
4748
}
4849

4950
/// A new pattern for `PATCH` request.
50-
public static func patch(_ url: URL, headerFields: [String: String] = [:]) -> RequestPattern {
51+
public static func patch(_ url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) -> RequestPattern {
5152
return RequestPattern(method: "PATCH", url: url, headerFields: headerFields)
5253
}
5354

5455
/// A new pattern for `DELETE` request.
55-
public static func delete(_ url: URL, headerFields: [String: String] = [:]) -> RequestPattern {
56+
public static func delete(_ url: PatternRepresentable, headerFields: [String: PatternRepresentable] = [:]) -> RequestPattern {
5657
return RequestPattern(method: "DELETE", url: url, headerFields: headerFields)
5758
}
5859

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import CatbirdAPI
2+
3+
extension Pattern {
4+
5+
func match(_ someValue: String) -> Bool {
6+
let pattern = value
7+
switch kind {
8+
case .equal:
9+
return pattern == someValue
10+
case .wildcard:
11+
return Wildcard(pattern: pattern).check(someValue)
12+
case .regexp:
13+
return someValue.range(of: pattern , options: [.regularExpression, .caseInsensitive]) != nil
14+
}
15+
}
16+
17+
}

Sources/CatbirdApp/Model/RequestPattern+HTTP.swift

+12-5
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,24 @@ extension RequestPattern {
66
init(httpRequest: HTTPRequest) {
77
var headers: [String: String] = [:]
88
httpRequest.headers.forEach { headers[$0.name] = $0.value }
9-
9+
1010
self.init(
1111
method: httpRequest.method.string,
1212
url: httpRequest.url,
1313
headerFields: headers)
1414
}
1515

1616
func match(_ httpRequest: HTTPRequest) -> Bool {
17-
// TODO: check headers
18-
19-
return httpRequest.method.string == method
20-
&& httpRequest.url == url
17+
var result = httpRequest.method.string == method
18+
result = result && url.match(httpRequest.url.absoluteString)
19+
for patternHeader in headerFields {
20+
// We not support multiple headers with the same key
21+
if let value = httpRequest.headers[patternHeader.key].first {
22+
result = result && patternHeader.value.match(value)
23+
} else {
24+
return false
25+
}
26+
}
27+
return result
2128
}
2229
}
+87
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Foundation
2+
3+
/// Convert wildcard pattern to regular expression and check string match.
4+
public struct Wildcard {
5+
6+
let pattern: String
7+
8+
public init(pattern: String) {
9+
self.pattern = pattern
10+
}
11+
12+
/// Translate wildcard pattern to regular expression pattern
13+
///
14+
/// - Returns: Regular expression pattern string
15+
public func regexPattern() -> String {
16+
let patternChars = [Character](pattern)
17+
var result = ""
18+
var index = 0
19+
var inGroup = false
20+
21+
while index < patternChars.count {
22+
let char = patternChars[index]
23+
24+
switch char {
25+
case "/", "$", "^", "+", ".", "(", ")", "=", "!", "|":
26+
result.append("\\\(char)")
27+
case "\\":
28+
// Escaping next character
29+
if index + 1 < patternChars.count {
30+
result.append("\\")
31+
result.append(patternChars[index + 1])
32+
index += 1
33+
} else {
34+
result.append("\\\\")
35+
}
36+
case "?":
37+
result.append(".")
38+
case "[", "]":
39+
result.append(char)
40+
case "{":
41+
inGroup = true
42+
result.append("(")
43+
case "}":
44+
inGroup = false
45+
result.append(")")
46+
case ",":
47+
if inGroup {
48+
result.append("|")
49+
} else {
50+
result.append("\\\(char)")
51+
}
52+
case "*":
53+
// Move over all consecutive "*"'s.
54+
while(index + 1 < patternChars.count && patternChars[index + 1] == "*") {
55+
index += 1
56+
}
57+
// Treat any number of "*" as one
58+
result.append(".*")
59+
default:
60+
result.append(char)
61+
}
62+
index += 1
63+
}
64+
65+
return "^\(result)$"
66+
}
67+
68+
/// Make regular expression object from wildcard pattern
69+
///
70+
/// - Returns: An instance of NSRegularExpression
71+
public func toRegex(caseInsensitive: Bool = true) throws -> NSRegularExpression {
72+
var options: NSRegularExpression.Options = [.anchorsMatchLines]
73+
if caseInsensitive {
74+
options = options.union([.caseInsensitive])
75+
}
76+
return try NSRegularExpression(pattern: regexPattern(), options: options)
77+
}
78+
79+
/// Checks that given string match to wildcard pattern
80+
public func check(_ testingString: String, caseInsensitive: Bool = true) -> Bool {
81+
var options: String.CompareOptions = [.regularExpression]
82+
if caseInsensitive {
83+
options = options.union([.caseInsensitive])
84+
}
85+
return testingString.range(of: regexPattern() , options: options) != nil
86+
}
87+
}

Sources/CatbirdApp/Store/BagsResponseStore.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@ import CatbirdAPI
22

33
protocol BagsResponseStore {
44

5-
var bags: [RequestPattern : ResponseData] { get }
5+
var bags: [RequestBag] { get }
66
}

Sources/CatbirdApp/Store/DataResponseStore.swift

+15-7
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,28 @@ import Vapor
33

44
final class DataResponseStore: ResponseStore, BagsResponseStore {
55

6-
private(set) var bags: [RequestPattern : ResponseData] = [:]
6+
private(set) var bags: [RequestBag] = []
77

88
// MARK: - ResponseStore
99

1010
func response(for request: Request) throws -> Response {
11-
let pattern = RequestPattern(method: request.http.method.string,
12-
url: request.http.url,
13-
headerFields: [:])
14-
guard let response = bags[pattern] else { throw Abort(.notFound) }
15-
return request.response(http: response.httpResponse)
11+
for bag in bags where bag.pattern.match(request.http) {
12+
return request.response(http: bag.data.httpResponse)
13+
}
14+
throw Abort(.notFound)
1615
}
1716

1817
func setResponse(data: ResponseData?, for pattern: RequestPattern) throws {
19-
bags[pattern] = data
18+
guard let data = data else {
19+
bags.removeAll { $0.pattern == pattern }
20+
return
21+
}
22+
let bag = RequestBag(pattern: pattern, data: data)
23+
if let index = bags.firstIndex(where: { $0.pattern == pattern }) {
24+
bags[index] = bag
25+
} else {
26+
bags.append(bag)
27+
}
2028
}
2129

2230
func removeAllResponses() throws {

Sources/CatbirdApp/Store/FileResponseStore.swift

+12-14
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,15 @@ final class FileResponseStore: ResponseStore {
2323

2424
func setResponse(data: ResponseData?, for pattern: RequestPattern) throws {
2525
guard let body = data?.body else { return }
26-
27-
let url = URL(fileURLWithPath: path + pattern.url.path, isDirectory: false)
28-
try createDirectories(for: pattern.url)
26+
27+
let patternPath: String
28+
if case .equal = pattern.url.kind, let url = URL(string: pattern.url.value) {
29+
patternPath = url.path
30+
} else {
31+
patternPath = pattern.url.value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? ""
32+
}
33+
let url = URL(fileURLWithPath: path + patternPath, isDirectory: false)
34+
try createDirectories(for: url)
2935
try body.write(to: url)
3036
}
3137

@@ -34,16 +40,8 @@ final class FileResponseStore: ResponseStore {
3440
// MARK: - Private
3541

3642
private func createDirectories(for url: URL) throws {
37-
// Remove first component "/" and last with file name
38-
let pathComponents = url.pathComponents.dropFirst().dropLast()
39-
guard !pathComponents.isEmpty else { return }
40-
41-
try pathComponents
42-
.indices
43-
.map { pathComponents[...$0].joined(separator: "/") }
44-
.map { "\(path)/\($0)" }
45-
.filter { !fileManager.fileExists(atPath: $0, isDirectory: nil) }
46-
.map { URL(fileURLWithPath: $0, isDirectory: true) }
47-
.forEach { try fileManager.createDirectory(at: $0, withIntermediateDirectories: true) }
43+
// Remove file name
44+
let dirUrl = url.deletingLastPathComponent()
45+
try fileManager.createDirectory(at: dirUrl, withIntermediateDirectories: true)
4846
}
4947
}

0 commit comments

Comments
 (0)