diff --git a/MockDuck/Sources/MockBundle.swift b/MockDuck/Sources/MockBundle.swift index 357b638..213941a 100644 --- a/MockDuck/Sources/MockBundle.swift +++ b/MockDuck/Sources/MockBundle.swift @@ -29,59 +29,105 @@ 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 baseURL = recordingURL else { return [] } + + var responses = [MockRequestResponse]() + let targetURL = baseURL.appendingPathComponent(hostname) + + let results = FileManager.default.enumerator( + at: targetURL, + includingPropertiesForKeys: [.isDirectoryKey], + options: []) + if let results = results { + for item in results { + var isDir = ObjCBool(false) + /// 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 + let item = item as? URL, + FileManager.default.fileExists(atPath: item.path, isDirectory: &isDir), + !isDir.boolValue, + !item.lastPathComponent.contains("-response"), + let relativePath = item.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 +215,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 + } }