From a4233e2009838a6df938273e1e255803f1bb4143 Mon Sep 17 00:00:00 2001 From: Pete Walters Date: Wed, 18 Sep 2019 15:30:33 -0500 Subject: [PATCH 1/3] Add better debug descriptions for request/response objects --- MockDuck/Sources/MockRequest.swift | 6 +++++- MockDuck/Sources/MockRequestResponse.swift | 25 +++++++++++++++++++++- MockDuck/Sources/MockResponse.swift | 13 ++++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/MockDuck/Sources/MockRequest.swift b/MockDuck/Sources/MockRequest.swift index 9eabc12..47b685f 100644 --- a/MockDuck/Sources/MockRequest.swift +++ b/MockDuck/Sources/MockRequest.swift @@ -10,7 +10,7 @@ import Foundation /// A very basic wrapper around URLRequest that allows us to read and write this data to disk /// using Codable without having to make URLRequest itself conform to Codable. -final class MockRequest { +final class MockRequest: CustomDebugStringConvertible { var request: URLRequest private(set) lazy var normalizedRequest: URLRequest = { @@ -20,6 +20,10 @@ final class MockRequest { init(request: URLRequest) { self.request = request } + + public var debugDescription: String { + return "\(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")" + } } extension MockRequest: Codable { diff --git a/MockDuck/Sources/MockRequestResponse.swift b/MockDuck/Sources/MockRequestResponse.swift index 72caf3c..5b07477 100644 --- a/MockDuck/Sources/MockRequestResponse.swift +++ b/MockDuck/Sources/MockRequestResponse.swift @@ -9,7 +9,7 @@ import Foundation /// A basic container for holding a request, a response, and any associated data. -final class MockRequestResponse: Codable { +final class MockRequestResponse: Codable, CustomDebugStringConvertible { enum MockFileTarget { case request @@ -141,4 +141,27 @@ final class MockRequestResponse: Codable { requestWrapper = try container.decode(MockRequest.self, forKey: .requestWrapper) responseWrapper = try container.decodeIfPresent(MockResponse.self, forKey: .responseWrapper) } + + // MARK: Debug + + public var debugDescription: String { + var result = "\n" + + if let request = fileName(for: .request) { + result.append("Request: \(request)\n") + result.append("\t\(requestWrapper)\n") + } + if let requestBody = fileName(for: .requestBody) { + result.append("Request Body: \(requestBody)\n") + } + if let responseData = fileName(for: .responseData) { + result.append("Response Data: \(responseData)\n") + + if let response = responseWrapper { + result.append("\t\(response)\n") + } + } + + return result + } } diff --git a/MockDuck/Sources/MockResponse.swift b/MockDuck/Sources/MockResponse.swift index 1699ec9..ef0144b 100644 --- a/MockDuck/Sources/MockResponse.swift +++ b/MockDuck/Sources/MockResponse.swift @@ -9,7 +9,7 @@ import Foundation /// A basic class that holds onto a URLResponse and its associated data. -public final class MockResponse { +public final class MockResponse: CustomDebugStringConvertible { var response: URLResponse var responseData: Data? @@ -82,6 +82,17 @@ public final class MockResponse { let data = try JSONSerialization.data(withJSONObject: json, options: []) try self.init(for: request, data: data, statusCode: statusCode, headers: headers) } + + public var debugDescription: String { + var result = "" + if let httpResponse = response as? HTTPURLResponse + { + result.append("\(httpResponse.statusCode) ") + } + result.append("\(response.url?.absoluteString ?? "")") + result.append(" (\(response.expectedContentLength) bytes)") + return result + } } // MARK: - Codable From d4551dae54bd62119fcd6fd40a5c11b6efb74939 Mon Sep 17 00:00:00 2001 From: Pete Walters Date: Wed, 18 Sep 2019 15:31:22 -0500 Subject: [PATCH 2/3] Fix hash method to consider the httpBodyStreamData --- MockDuck/Sources/MockRequestResponse.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MockDuck/Sources/MockRequestResponse.swift b/MockDuck/Sources/MockRequestResponse.swift index 5b07477..0b9a699 100644 --- a/MockDuck/Sources/MockRequestResponse.swift +++ b/MockDuck/Sources/MockRequestResponse.swift @@ -107,6 +107,10 @@ final class MockRequestResponse: Codable, CustomDebugStringConvertible { hashData.append(body) } + if let bodyData = normalizedRequest.httpBodyStreamData { + hashData.append(bodyData) + } + if !hashData.isEmpty { return String(CryptoUtils.md5(hashData).prefix(8)) } else { From ccbf277595b0702126bdf7ae549962a290735376 Mon Sep 17 00:00:00 2001 From: Pete Walters Date: Wed, 18 Sep 2019 15:32:31 -0500 Subject: [PATCH 3/3] Add support for extracting recorded requests --- MockDuck/Sources/MockBundle.swift | 156 ++++++++++++----- MockDuck/Sources/MockDuck.swift | 12 ++ MockDuck/Sources/MockRequestResponse.swift | 10 +- MockDuckTests/Sources/MockBundleTests.swift | 184 +++++++++++++++++++- 4 files changed, 310 insertions(+), 52 deletions(-) diff --git a/MockDuck/Sources/MockBundle.swift b/MockDuck/Sources/MockBundle.swift index 357b638..167bc33 100644 --- a/MockDuck/Sources/MockBundle.swift +++ b/MockDuck/Sources/MockBundle.swift @@ -29,59 +29,108 @@ final class MockBundle { /// - Returns: The MockRequestResponse, if it can be loaded func loadResponse(for requestResponse: MockRequestResponse) -> Bool { guard let fileName = requestResponse.fileName(for: .request) else { return false } - - var targetURL: URL? - var targetLoadingURL: URL? let request = requestResponse.request + var loadedPath: String? + var loadedResponse: MockResponse? if let response = checkRequestHandlers(for: request) { - requestResponse.responseWrapper = response - return true - } else if - let inputURL = loadingURL?.appendingPathComponent(fileName), - FileManager.default.fileExists(atPath: inputURL.path) - { - os_log("Loading request %@ from: %@", log: MockDuck.log, type: .debug, "\(request)", inputURL.path) - targetURL = inputURL - targetLoadingURL = loadingURL - } else if - let inputURL = recordingURL?.appendingPathComponent(fileName), - FileManager.default.fileExists(atPath: inputURL.path) - { - os_log("Loading request %@ from: %@", log: MockDuck.log, type: .debug, "\(request)", inputURL.path) - targetURL = inputURL - targetLoadingURL = recordingURL + loadedResponse = response + } else if let response = loadResponseFile(relativePath: fileName, baseURL: loadingURL) { + loadedPath = loadingURL?.path ?? "" + fileName + loadedResponse = response.responseWrapper + } else if let response = loadResponseFile(relativePath: fileName, baseURL: recordingURL) { + loadedPath = recordingURL?.path ?? "" + fileName + loadedResponse = response.responseWrapper } else { os_log("Request %@ not found on disk. Expected file name: %@", log: MockDuck.log, type: .debug, "\(request)", fileName) } - - if - let targetURL = targetURL, - let targetLoadingURL = targetLoadingURL - { - let decoder = JSONDecoder() - - do { - let data = try Data(contentsOf: targetURL) - - let loaded = try decoder.decode(MockRequestResponse.self, from: data) - requestResponse.responseWrapper = loaded.responseWrapper - - // Load the response data if the format is supported. - // This should be the same filename with a different extension. - if let dataFileName = requestResponse.fileName(for: .responseData) { - let dataURL = targetLoadingURL.appendingPathComponent(dataFileName) - requestResponse.responseData = try Data(contentsOf: dataURL) - } - - return true - } catch { - os_log("Error decoding JSON: %@", log: MockDuck.log, type: .error, "\(error)") + + if let response = loadedResponse { + requestResponse.responseWrapper = response + if let path = loadedPath { + os_log("Loading request %@ from: %@", + log: MockDuck.log, + type: .debug, + "\(request)", + path) } + return true } - return false } + + /// Takes a URL and attempts to parse the file at that location into a MockRequestResponse + /// If the file doesn't exist, or isn't in the expected MockDuck format, nil is returned + /// + /// - Parameter targetURL: URL that should be loaded from file + /// - Returns: MockRequestResponse if the request exists at that URL + func loadResponseFile(relativePath: String, baseURL: URL?) -> MockRequestResponse? { + guard let baseURL = baseURL else { return nil } + let targetURL = baseURL.appendingPathComponent(relativePath) + guard FileManager.default.fileExists(atPath: targetURL.path) else { return nil} + + let decoder = JSONDecoder() + + do { + let data = try Data(contentsOf: targetURL) + + let response = try decoder.decode(MockRequestResponse.self, from: data) + + // Load the response data if the format is supported. + // This should be the same filename with a different extension. + if let dataFileName = response.fileName(for: .responseData) { + let dataURL = baseURL.appendingPathComponent(dataFileName) + response.responseData = try? Data(contentsOf: dataURL) + } + + return response + } catch { + os_log("Error decoding JSON: %@", log: MockDuck.log, type: .error, "\(error)") + } + return nil + } + + /// Takes a passed in hostname and returns all the recorded mocks for that URL. + /// If an empty string is passed in, all recordings will be returned. + /// + /// - Parameter hostname: String representing the hostname to load requests from. + /// - Returns: An array of MockRequestResponse for each request under that domain + func getResponses(for hostname: String) -> [MockRequestResponse] { + guard let recordingURL = recordingURL else { return [] } + + let baseURL = recordingURL.resolvingSymlinksInPath() + var responses = [MockRequestResponse]() + let targetURL = baseURL.appendingPathComponent(hostname) + + let results = FileManager.default.enumerator( + at: targetURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: []) + + if let results = results { + for case let item as URL in results { + var isDir = ObjCBool(false) + let itemURL = item.resolvingSymlinksInPath() + + /// Check if the item: + /// 1) isn't a directory + /// 2) doesn't end in '-response' (a sidecar file) + /// If so, load it using loadResponseFile so any associated + /// '-response' file is also loaded with the repsonse. + if + FileManager.default.fileExists(atPath: itemURL.path, isDirectory: &isDir), + !isDir.boolValue, + !itemURL.lastPathComponent.contains("-response"), + let relativePath = itemURL.pathRelative(to: baseURL), + let response = loadResponseFile(relativePath: relativePath, baseURL: recordingURL) + { + responses.append(response) + } + } + } + + return responses + } /// If recording is enabled, this method saves the request to the filesystem. If the request /// body or the response data are of a certain type 'jpg/png/gif/json', the request is saved @@ -169,3 +218,24 @@ final class MockBundle { } } } + + +extension URL { + func pathRelative(to url: URL) -> String? { + guard + host == url.host, + scheme == url.scheme + else { return nil } + + let components = self.standardized.pathComponents + let baseComponents = url.standardized.pathComponents + + if components.count < baseComponents.count { return nil } + for (index, baseComponent) in baseComponents.enumerated() { + let component = components[index] + if component != baseComponent { return nil } + } + + return components[baseComponents.count.. [MockRequestResponse] { + checkConfigureMockDuck() + return mockBundle.getResponses(for: hostname) + } // MARK: - Internal Use Only diff --git a/MockDuck/Sources/MockRequestResponse.swift b/MockDuck/Sources/MockRequestResponse.swift index 0b9a699..8b7a04a 100644 --- a/MockDuck/Sources/MockRequestResponse.swift +++ b/MockDuck/Sources/MockRequestResponse.swift @@ -9,7 +9,7 @@ import Foundation /// A basic container for holding a request, a response, and any associated data. -final class MockRequestResponse: Codable, CustomDebugStringConvertible { +public final class MockRequestResponse: Codable, CustomDebugStringConvertible { enum MockFileTarget { case request @@ -19,7 +19,7 @@ final class MockRequestResponse: Codable, CustomDebugStringConvertible { // MARK: - Properties - var request: URLRequest { + public var request: URLRequest { get { return requestWrapper.request } @@ -28,11 +28,11 @@ final class MockRequestResponse: Codable, CustomDebugStringConvertible { } } - var response: URLResponse? { + public var response: URLResponse? { return responseWrapper?.response } - var responseData: Data? { + public var responseData: Data? { get { return responseWrapper?.responseData } @@ -140,7 +140,7 @@ final class MockRequestResponse: Codable, CustomDebugStringConvertible { case responseWrapper = "response" } - init(from decoder: Decoder) throws { + public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) requestWrapper = try container.decode(MockRequest.self, forKey: .requestWrapper) responseWrapper = try container.decodeIfPresent(MockResponse.self, forKey: .responseWrapper) diff --git a/MockDuckTests/Sources/MockBundleTests.swift b/MockDuckTests/Sources/MockBundleTests.swift index 938e133..be4ebbf 100644 --- a/MockDuckTests/Sources/MockBundleTests.swift +++ b/MockDuckTests/Sources/MockBundleTests.swift @@ -41,14 +41,19 @@ class MockBundleTests: XCTestCase { let responseData = Data([1, 2, 3, 4]) let headerName = "X-BUZZFEED-TEST" let headerValue = "AMAZING" + let headerFields = [headerName: headerValue] let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") let request = URLRequest(url: url) - let response: HTTPURLResponse! = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: [headerName: headerValue]) - let mockResponse = MockResponse(response: response, responseData: responseData) - let requestResponse = MockRequestResponse(request: request, mockResponse: mockResponse) + MockDuck.recordingURL = recordingURL - MockDuck.mockBundle.record(requestResponse: requestResponse) + + _ = recordResponse( + url: url, + statusCode: statusCode, + responseData: responseData, + headerFields: headerFields) + let mockRequestResponse = MockRequestResponse(request: request) XCTAssertTrue(MockDuck.mockBundle.loadResponse(for: mockRequestResponse)) XCTAssertNotNil(mockRequestResponse.response) @@ -123,4 +128,175 @@ class MockBundleTests: XCTestCase { waitForExpectations(timeout: 2.0, handler: nil) } + + func testGetResponse() { + let statusCode = 530 + let responseData = Data([1, 2, 3, 4]) + let headerName = "X-BUZZFEED-TEST" + let headerValue = "AMAZING" + let headerFields = [headerName: headerValue] + let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + _ = recordResponse( + url: url, + statusCode: statusCode, + responseData: responseData, + headerFields: headerFields) + + let mockRequestResponse = MockDuck.mockBundle.getResponses(for: "www.buzzfeed.com").first! + + XCTAssertNotNil(mockRequestResponse.response) + XCTAssertEqual((mockRequestResponse.response as! HTTPURLResponse).statusCode, statusCode) + XCTAssertEqual(mockRequestResponse.responseData, responseData) + XCTAssertEqual(mockRequestResponse.response?.headers?[headerName], headerValue) + } + + func testGetResponseSidecar() { + let responseData = try! JSONSerialization.data(withJSONObject: ["test": "value"]) + let headerName = "Content-Type" + let headerValue = "application/json" + let headerFields = [headerName: headerValue] + let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + _ = recordResponse( + url: url, + responseData: responseData, + headerFields: headerFields) + + let mockRequestResponse = MockDuck.mockBundle.getResponses(for: "www.buzzfeed.com").first! + + XCTAssertNotNil(mockRequestResponse.response) + XCTAssertEqual(mockRequestResponse.responseData, responseData) + XCTAssertEqual(mockRequestResponse.response?.headers?[headerName], headerValue) + } + + func testGetResponseSimpleURL() { + let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + _ = recordResponse(url: url) + let mockRequestResponse = MockDuck.mockBundle.getResponses(for: "www.buzzfeed.com").first! + XCTAssertNotNil(mockRequestResponse.response) + } + + func testGetResponseMultipleURL() { + let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + _ = recordResponse(url: url) + let mockRequestResponse = MockDuck.mockBundle.getResponses(for: "www.buzzfeed.com").first! + XCTAssertNotNil(mockRequestResponse.response) + } + + func testGetResponseMissingFIle() { + let url: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + _ = recordResponse(url: url) + let mockRequestResponse = MockDuck.mockBundle.getResponses(for: "www.test.com").first + XCTAssertNil(mockRequestResponse) + } + + func testGetResponseFilterMultipleRequests() { + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + let url1: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + let url2: URL! = URL(string: "https://www.hodor.com/hodor") + _ = recordResponse(url: url1) + _ = recordResponse(url: url2) + + let count = MockDuck.mockBundle.getResponses(for: "www.hodor.com").count + XCTAssertEqual(count, 1) + } + + func testGetResponseMultipleRequests() { + MockDuck.loadingURL = loadingURL + MockDuck.recordingURL = recordingURL + + let url1: URL! = URL(string: "https://www.buzzfeed.com/mother-of-dragons") + let url2: URL! = URL(string: "https://www.buzzfeed.com/hodor") + _ = recordResponse(url: url1) + _ = recordResponse(url: url2) + + let count = MockDuck.mockBundle.getResponses(for: "www.buzzfeed.com").count + XCTAssertEqual(count, 2) + } + + func testRelativeURL1() { + let url1: URL! = URL(string: "https://www.buzzfeed.com/mother/of/dragons") + let url2: URL! = URL(string: "https://www.buzzfeed.com/") + + let result = url1.pathRelative(to: url2) + XCTAssertEqual("mother/of/dragons", result) + } + + func testRelativeURL2() { + let url1: URL! = URL(string: "https://www.buzzfeed.com/mother/of/dragons") + let url2: URL! = URL(string: "https://www.buzzfeed.com/mother/of") + + let result = url1.pathRelative(to: url2) + XCTAssertEqual("dragons", result) + } + + func testRelativeURL3() { + let url1: URL! = URL(string: "https://www.buzzfeed.com") + let url2: URL! = URL(string: "https://www.buzzfeed.com/mother/of") + + let result = url1.pathRelative(to: url2) + XCTAssertNil(result) + } + + func testRelativeURL4() { + let url1: URL! = URL(string: "https://www.buzzfeed.com/mother/of/dragons") + let url2: URL! = URL(string: "https://www.hodor.com/") + + let result = url1.pathRelative(to: url2) + XCTAssertNil(result) + } + + func testRelativeURL5() { + let url1: URL! = URL(string: "file:///a/b/c/d/e/f") + let url2: URL! = URL(string: "file://a/b/c/") + + let result = url1.pathRelative(to: url2) + XCTAssertNil(result) + } + + // MARK: - Utilities + + func recordResponse( + url: URL, + statusCode: Int = 200, + responseData: Data? = nil, + headerFields: [String: String]? = nil) -> MockRequestResponse + { + let request = URLRequest(url: url) + + let response: HTTPURLResponse! = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: headerFields) + let mockResponse = MockResponse( + response: response, + responseData: responseData) + let requestResponse = MockRequestResponse( + request: request, + mockResponse: mockResponse) + MockDuck.mockBundle.record(requestResponse: requestResponse) + return requestResponse + } }