From 2dd9e014d573d9b774158f4eb98f177dd6f99115 Mon Sep 17 00:00:00 2001 From: Teameh <1330668+teameh@users.noreply.github.com> Date: Thu, 3 Oct 2024 16:12:24 +0200 Subject: [PATCH] POC response handler for dynamic requests --- Sources/Mocker/Mock.swift | 27 ++++++++ Sources/Mocker/MockingURLProtocol.swift | 23 +++++-- Sources/Mocker/OnRequestHandler.swift | 3 +- Sources/Mocker/ResponseHandler.swift | 90 +++++++++++++++++++++++++ Tests/MockerTests/MockerTests.swift | 32 +++++++++ 5 files changed, 169 insertions(+), 6 deletions(-) create mode 100644 Sources/Mocker/ResponseHandler.swift diff --git a/Sources/Mocker/Mock.swift b/Sources/Mocker/Mock.swift index d1407b2..83f983f 100644 --- a/Sources/Mocker/Mock.swift +++ b/Sources/Mocker/Mock.swift @@ -101,6 +101,9 @@ public struct Mock: Equatable { /// The on request handler which will be executed everytime this `Mock` was started. Can be used within unit tests for validating that a request has been started. The handler must be set before calling `register`. public var onRequestHandler: OnRequestHandler? + /// Optional response handler which could be used to dynamically generate the response + public var responseHandler: ResponseHandler? + /// Can only be set internally as it's used by the `expectationForRequestingMock(_:)` method. var onRequestExpectation: XCTestExpectation? @@ -290,6 +293,30 @@ public struct Mock: Equatable { ) } + /// Creates a `Mock` for the given `URLRequest`. + /// + /// - Parameters: + /// - request: The URLRequest, from which the URL and request method is used to match for and to return the mocked data for. + /// - ignoreQuery: If `true`, checking the URL will ignore the query and match only for the scheme, host and path. Defaults to `false`. + /// - cacheStoragePolicy: The caching strategy. Defaults to `notAllowed`. + /// - responseHandler: The response handler to dynamicly generate the response for this Mock + public init(request: URLRequest, ignoreQuery: Bool = false, cacheStoragePolicy: URLCache.StoragePolicy = .notAllowed, responseHandler: ResponseHandler) { + guard let requestHTTPMethod = Mock.HTTPMethod(rawValue: request.httpMethod ?? "") else { + preconditionFailure("Unexpected http method") + } + + self.init( + url: request.url, + ignoreQuery: ignoreQuery, + cacheStoragePolicy: cacheStoragePolicy, + statusCode: 999, // unused, see responseHandler + data: [requestHTTPMethod: "responseHandler should have been used instead of this".data(using: .utf8)!], + fileExtensions: nil + ) + + self.responseHandler = responseHandler + } + /// Registers the mock with the shared `Mocker`. public func register() { Mocker.register(self) diff --git a/Sources/Mocker/MockingURLProtocol.swift b/Sources/Mocker/MockingURLProtocol.swift index 020dace..4f1c80b 100644 --- a/Sources/Mocker/MockingURLProtocol.swift +++ b/Sources/Mocker/MockingURLProtocol.swift @@ -36,16 +36,29 @@ open class MockingURLProtocol: URLProtocol { /// Returns Mocked data based on the mocks register in the `Mocker`. Will end up in an error when no Mock data is found for the request. override public func startLoading() { - guard - let mock = Mocker.mock(for: request), - let response = HTTPURLResponse(url: mock.request.url!, statusCode: mock.statusCode, httpVersion: Mocker.httpVersion.rawValue, headerFields: mock.headers), - let data = mock.data(for: request) - else { + guard let mock = Mocker.mock(for: request) else { print("\n\n 🚨 No mocked data found for url \(String(describing: request.url?.absoluteString)) method \(String(describing: request.httpMethod)). Did you forget to use `register()`? 🚨 \n\n") client?.urlProtocol(self, didFailWithError: Error.missingMockedData(url: String(describing: request.url?.absoluteString))) return } + let response: HTTPURLResponse + let data: Data + if let responseHandler = mock.responseHandler { + (response, data) = responseHandler.handleRequest(request) + } else if let httpResponse = HTTPURLResponse( + url: mock.request.url!, + statusCode: mock.statusCode, + httpVersion: Mocker.httpVersion.rawValue, + headerFields: mock.headers + ), let mockData = mock.data(for: request) { + response = httpResponse + data = mockData + } else { + print("\n\n 🚨 Unable to create HTTPURLResponse for mock for url \(String(describing: request.url?.absoluteString)) method \(String(describing: request.httpMethod)). 🚨 \n\n") + return + } + if let onRequestHandler = mock.onRequestHandler { onRequestHandler.handleRequest(request) } diff --git a/Sources/Mocker/OnRequestHandler.swift b/Sources/Mocker/OnRequestHandler.swift index e605d1d..99bf6f8 100644 --- a/Sources/Mocker/OnRequestHandler.swift +++ b/Sources/Mocker/OnRequestHandler.swift @@ -113,7 +113,7 @@ public struct OnRequestHandler { } } -private extension URLRequest { +extension URLRequest { /// We need to use the http body stream data as the URLRequest once launched converts the `httpBody` to this stream of data. func httpBodyStreamData() -> Data? { guard let bodyStream = self.httpBodyStream else { return nil } @@ -136,3 +136,4 @@ private extension URLRequest { return data } } + diff --git a/Sources/Mocker/ResponseHandler.swift b/Sources/Mocker/ResponseHandler.swift new file mode 100644 index 0000000..18bda66 --- /dev/null +++ b/Sources/Mocker/ResponseHandler.swift @@ -0,0 +1,90 @@ +// +// RequestResponseHandler.swift +// +// +// Created by Tieme on 03/10/2024. +// Copyright © 2022 WeTransfer. All rights reserved. + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// A handler for a dynamic response +public struct ResponseHandler { + + public typealias OnRequest = (_ request: URLRequest, _ httpBody: HTTPBody?) -> (HTTPURLResponse, Data) + + private let internalCallback: (_ request: URLRequest) -> (HTTPURLResponse, Data) + + /// Creates a new request handler using the given `HTTPBody` type, which can be any `Decodable`. + /// - Parameters: + /// - httpBodyType: The decodable type to use for parsing the request body. + /// - callback: The callback which will be called just before the request executes. + public init(httpBodyType: HTTPBody.Type?, callback: @escaping OnRequest) { + self.init(httpBodyType: httpBodyType, jsonDecoder: JSONDecoder(), callback: callback) + } + + /// Creates a new request handler using the given `HTTPBody` type, which can be any `Decodable` and decoding it using the provided `JSONDecoder`. + /// - Parameters: + /// - httpBodyType: The decodable type to use for parsing the request body. + /// - jsonDecoder: The decoder to use for decoding the request body. + /// - callback: The callback which will be called just before the request executes. + public init(httpBodyType: HTTPBody.Type?, jsonDecoder: JSONDecoder, callback: @escaping OnRequest) { + self.internalCallback = { request in + guard + let httpBody = request.httpBodyStreamData() ?? request.httpBody, + let decodedObject = try? jsonDecoder.decode(HTTPBody.self, from: httpBody) + else { + return callback(request, nil) + } + return callback(request, decodedObject) + } + } + + /// Creates a new request handler using the given callback to call on request without parsing the body arguments. + /// - Parameter requestCallback: The callback which will be executed just before the request executes, containing the request. + public init(requestCallback: @escaping (_ request: URLRequest) -> (HTTPURLResponse, Data)) { + self.internalCallback = requestCallback + } + + /// Creates a new request handler using the given callback to call on request without parsing the body arguments and without passing the request. + /// - Parameter callback: The callback which will be executed just before the request executes. + public init(callback: @escaping () -> (HTTPURLResponse, Data)) { + self.internalCallback = { _ in + callback() + } + } + + /// Creates a new request handler using the given callback to call on request. + /// - Parameter jsonDictionaryCallback: The callback that executes just before the request executes, containing the HTTP Body Arguments as a JSON Object Dictionary. + public init(jsonDictionaryCallback: @escaping ((_ request: URLRequest, _ httpBodyArguments: [String: Any]?) -> (HTTPURLResponse, Data))) { + self.internalCallback = { request in + guard + let httpBody = request.httpBodyStreamData() ?? request.httpBody, + let jsonObject = try? JSONSerialization.jsonObject(with: httpBody, options: .fragmentsAllowed) as? [String: Any] + else { + return jsonDictionaryCallback(request, nil) + } + return jsonDictionaryCallback(request, jsonObject) + } + } + + /// Creates a new request handler using the given callback to call on request. + /// - Parameter jsonDictionaryCallback: The callback that executes just before the request executes, containing the HTTP Body Arguments as a JSON Object Array. + public init(jsonArrayCallback: @escaping ((_ request: URLRequest, _ httpBodyArguments: [[String: Any]]?) -> (HTTPURLResponse, Data))) { + self.internalCallback = { request in + guard + let httpBody = request.httpBodyStreamData() ?? request.httpBody, + let jsonObject = try? JSONSerialization.jsonObject(with: httpBody, options: .fragmentsAllowed) as? [[String: Any]] + else { + return jsonArrayCallback(request, nil) + } + return jsonArrayCallback(request, jsonObject) + } + } + + func handleRequest(_ request: URLRequest) -> (HTTPURLResponse, Data) { + return internalCallback(request) + } +} diff --git a/Tests/MockerTests/MockerTests.swift b/Tests/MockerTests/MockerTests.swift index 5b85356..eeb0144 100644 --- a/Tests/MockerTests/MockerTests.swift +++ b/Tests/MockerTests/MockerTests.swift @@ -483,6 +483,38 @@ final class MockerTests: XCTestCase { wait(for: [onRequestExpectation], timeout: 2.0) } + func testResponseHandler() { + let requestExpectation = self.expectation(description: "Data request should succeed") + let responseHandlerExpectation = self.expectation(description: "Data request should start") + let originalURL = URL(string: "https://avatars3.githubusercontent.com/u/26250426?v=4&s=400")! + var request = URLRequest(url: originalURL) + + let mockedData = MockedData.botAvatarImageFileUrl.data + let mock = Mock(request: request, responseHandler: ResponseHandler(callback: { + responseHandlerExpectation.fulfill() + return ( + HTTPURLResponse( + url: originalURL, + statusCode: 200, + httpVersion: Mocker.httpVersion.rawValue, + headerFields: nil + )!, + mockedData + ) + })) + mock.register() + + mock.register() + URLSession.shared.dataTask(with: originalURL) { (data, _, error) in + XCTAssertNil(error) + XCTAssertEqual(data, mockedData, "Image should be returned mocked") + requestExpectation.fulfill() + }.resume() + + + wait(for: [responseHandlerExpectation, requestExpectation], enforceOrder: true) + } + /// It should call the mock after a delay. func testDelayedMock() { let nonDelayExpectation = expectation(description: "Data request should succeed")