├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Package.swift ├── README.md ├── Sources └── NetworkRequest │ ├── NetworkService │ ├── HTTPNetworkService.swift │ ├── MockNetworkService.swift │ ├── NetworkService.swift │ ├── NetworkServiceGroup.swift │ ├── RequestError.swift │ └── RequestHandler.swift │ └── Request │ ├── DecodableRequest.swift │ ├── EncodableRequest.swift │ └── Request.swift └── Tests ├── LinuxMain.swift └── NetworkRequestTests ├── HTTPNetworkTests.swift ├── MockNetworkTests.swift ├── NetworkServiceGroupTests.swift ├── RequestHandlerTests.swift ├── TestHelpers.swift └── XCTestManifests.swift /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | test: 5 | runs-on: macos-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: swift build 9 | run: swift build 10 | - name: swift test 11 | run: swift test 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | /.swiftpm -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "NetworkRequest", 7 | products: [ 8 | .library(name: "NetworkRequest", targets: ["NetworkRequest"]), 9 | ], 10 | targets: [ 11 | .target(name: "NetworkRequest", dependencies: []), 12 | .testTarget(name: "NetworkRequestTests", dependencies: ["NetworkRequest"]), 13 | ] 14 | ) 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetworkRequest 2 | 3 | A simple networking library for easily defining, executing and mocking network requests. 4 | 5 | ```swift 6 | 7 | struct GetPosts: JSONDecodableRequest { 8 | let userId: Int 9 | 10 | typealias ResponseType = [Post] 11 | let path: String = "/posts" 12 | var urlParams: [String: Any?] { return ["userId": userId] } 13 | } 14 | 15 | struct Post: Decodable { 16 | let userId: Int 17 | let id: Int 18 | let title: String 19 | let body: String 20 | } 21 | 22 | let networkService = HTTPNetworkService(baseURL: "https://jsonplaceholder.typicode.com") 23 | let request = GetPosts(userId: 2) 24 | 25 | networkService.makeRequest(request) { result in 26 | switch result { 27 | case .success(let posts): // posts is [Post] 28 | print(posts) 29 | case .failure(let error): // error is RequestError 30 | print(error) 31 | } 32 | } 33 | ``` 34 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/HTTPNetworkService.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Used to send requests via URL session 5 | public class HTTPNetworkService: NetworkService { 6 | 7 | /// Will be prepended to all request baseURLs and paths 8 | var baseURL: String? 9 | var urlSession: URLSession 10 | var completionQueue = DispatchQueue.main 11 | var headers: [String: String] 12 | var requestHandlers: [RequestHandler] 13 | 14 | public init(baseURL: String? = nil, headers: [String: String] = [:], urlSession: URLSession = .shared, requestHandlers: [RequestHandler] = []) { 15 | self.baseURL = baseURL 16 | self.headers = headers 17 | self.urlSession = urlSession 18 | self.requestHandlers = requestHandlers 19 | } 20 | 21 | @discardableResult 22 | open func makeRequest(_ request: R, completion: @escaping (RequestResult) -> Void) -> Cancellable? { 23 | 24 | let id = UUID().uuidString 25 | let requestHandler = AnyRequestHandler(id: id, request: request, handler: RequestHandlerGroup(handlers: requestHandlers)) 26 | requestHandler.requestCreated() 27 | 28 | func fail(_ error: RequestError) { 29 | complete(.failure(error)) 30 | } 31 | 32 | func complete(_ result: RequestResult) { 33 | completionQueue.async { 34 | requestHandler.requestCompleted(result: result.map { $0 }) 35 | completion(result) 36 | } 37 | } 38 | 39 | var urlRequest: URLRequest 40 | do { 41 | urlRequest = try request.getURLRequest() 42 | } catch { 43 | fail(.encodingError(error)) 44 | return nil 45 | } 46 | if let baseURL = baseURL { 47 | guard let url = urlRequest.url, let fullURL = URL(string: baseURL + url.absoluteString) else { 48 | fatalError("Invalid URL") 49 | } 50 | urlRequest.url = fullURL 51 | } 52 | 53 | for (name, value) in headers { 54 | urlRequest.setValue(value, forHTTPHeaderField: name) 55 | } 56 | 57 | var dataTask: URLSessionDataTask? 58 | 59 | let cancelBlock = CancelBlock { 60 | if let dataTask = dataTask { 61 | dataTask.cancel() 62 | } 63 | } 64 | requestHandler.modifyRequest(urlRequest) { result in 65 | 66 | switch result { 67 | case .success(let urlRequest): 68 | dataTask = self.urlSession.dataTask(with: urlRequest) { (data, urlResponse, error) in 69 | requestHandler.requestResponded(data: data, urlResponse: urlResponse as? HTTPURLResponse, error: error) 70 | if let error = error { 71 | if let urlError = error as? URLError { 72 | fail(.networkError(urlError, data, urlResponse as? HTTPURLResponse)) 73 | } else { 74 | fail(.genericError(error)) 75 | } 76 | } else if let data = data { 77 | let response = urlResponse as? HTTPURLResponse 78 | let statusCode = response?.statusCode ?? 0 79 | if request.validStatusCode(statusCode) { 80 | do { 81 | let value = try request.decodeResponse(data: data, statusCode: statusCode) 82 | complete(.success(value)) 83 | } catch { 84 | fail(.decodingError(data, error)) 85 | } 86 | } else { 87 | fail(.apiError(data, response)) 88 | } 89 | } else { 90 | fail(.noResponse) 91 | } 92 | } 93 | 94 | dataTask?.resume() 95 | requestHandler.requestSent() 96 | case .failure(let error): 97 | fail(.handlerError(error)) 98 | } 99 | } 100 | return cancelBlock 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/MockNetworkService.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Used for mocking out certain requests. If a request is not handled it will fail with RequestError.noResponse 5 | open class MockNetworkService: NetworkService { 6 | 7 | 8 | private var requests: [String: Result] = [:] 9 | private var dynamicRequests: [String: [(Any) -> RequestResult?]] = [:] 10 | 11 | public init() { 12 | 13 | } 14 | 15 | open func mock(request: R, result: RequestResult) { 16 | requests[request.description] = result.map { $0 } 17 | } 18 | 19 | open func mock(requestType: R.Type, result: RequestResult) { 20 | self.mock(requestType: requestType, { _ in result }) 21 | } 22 | 23 | open func mock(requestType: R.Type = R.self, _ response: @escaping (R) -> RequestResult?) { 24 | dynamicRequests[requestType.typeName, default: []].append( { request in 25 | response(request as! R)?.map { $0 } 26 | }) 27 | } 28 | 29 | open func mock(request: R, data: Data, statusCode: Int = 200) { 30 | do { 31 | let value = try request.decodeResponse(data: data, statusCode: statusCode) 32 | requests[request.description] = .success(value) 33 | } catch { 34 | requests[request.description] = .failure(.decodingError(data, error)) 35 | } 36 | } 37 | 38 | open func unmockAll() { 39 | requests.removeAll() 40 | dynamicRequests.removeAll() 41 | } 42 | 43 | @discardableResult 44 | open func makeRequest(_ request: R, completion: @escaping (RequestResult) -> Void) -> Cancellable? { 45 | if let requestResult = requests[request.description] { 46 | let result = requestResult.map { $0 as! R.ResponseType } 47 | completion(result) 48 | return nil 49 | } 50 | 51 | if let dynamicRequests = dynamicRequests[R.typeName] { 52 | for dynamicRequest in dynamicRequests { 53 | if let result = dynamicRequest(request) { 54 | completion(result.map { $0 as! R.ResponseType }) 55 | return nil 56 | } 57 | } 58 | } 59 | 60 | completion(.failure(.noResponse)) 61 | return nil 62 | } 63 | } 64 | 65 | fileprivate extension Request { 66 | 67 | static var typeName: String { return String(describing: Self.self)} 68 | } 69 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/NetworkService.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public protocol NetworkService { 5 | 6 | @discardableResult 7 | func makeRequest(_ request: R, completion: @escaping (RequestResult) -> Void) -> Cancellable? 8 | } 9 | 10 | public protocol Cancellable { 11 | func cancel() 12 | } 13 | 14 | public class CancelBlock: Cancellable { 15 | 16 | let block: () -> Void 17 | 18 | public init(block: @escaping () -> Void) { 19 | self.block = block 20 | } 21 | 22 | public func cancel() { 23 | block() 24 | } 25 | } 26 | 27 | extension URLSessionDataTask: Cancellable {} 28 | 29 | public typealias RequestResult = Result 30 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/NetworkServiceGroup.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /// Used for combining multiple network services. This is useful when mocking with MockNetworkService and then falling back to HTTPNetworkService. 5 | /// If one of the services doesn't handle the request by returning ResponseError.noResponse, the next service will run 6 | public class NetworkServiceGroup: NetworkService { 7 | 8 | public let services: [NetworkService] 9 | 10 | public init(services: [NetworkService]) { 11 | self.services = services 12 | } 13 | 14 | public func makeRequest(_ request: R, completion: @escaping (Result) -> Void) -> Cancellable? where R : Request { 15 | 16 | var serviceIndex = 0 17 | 18 | func makeServiceRequest() { 19 | let service = services[serviceIndex] 20 | service.makeRequest(request) { result in 21 | 22 | if case let .failure(error) = result, 23 | case .noResponse = error { 24 | if serviceIndex < self.services.count - 1 { 25 | serviceIndex += 1 26 | makeServiceRequest() 27 | } else { 28 | completion(result) 29 | } 30 | } else { 31 | completion(result) 32 | } 33 | } 34 | 35 | } 36 | if services.isEmpty { 37 | completion(.failure(.noResponse)) 38 | } else { 39 | makeServiceRequest() 40 | } 41 | return nil 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/RequestError.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public enum RequestError: Error { 5 | /// A general networking error 6 | case networkError(URLError, Data?, HTTPURLResponse?) 7 | 8 | /// The request returned a non success response 9 | case apiError(Data, HTTPURLResponse?) 10 | 11 | /// The response body failed decoding 12 | case decodingError(Data, Error) 13 | 14 | /// The request body failed encoding 15 | case encodingError(Error) 16 | 17 | /// A request handler failed the request 18 | case handlerError(Error) 19 | 20 | /// A generic error. Could theoritically be a network error if URLSession error is not a URLError, though that should never be the case 21 | case genericError(Error) 22 | 23 | /// No error or data was returned. Shouldn't happen under normal circumstances. Also used by mock service when no mock is provided 24 | case noResponse 25 | 26 | public var name: String { 27 | switch self { 28 | case .networkError: return "Network error" 29 | case .apiError: return "API Error" 30 | case .decodingError: return "Decoding Error" 31 | case .encodingError: return "Encoding Error" 32 | case .handlerError(let error): return "\(error)" 33 | case .genericError: return "Error" 34 | case .noResponse: return "No response" 35 | } 36 | } 37 | 38 | public var message: String { 39 | switch self { 40 | case let .networkError(error, _, _): return "\(error.localizedDescription)" 41 | case let .apiError(data, response): 42 | var error = "API returned \(response?.statusCode ?? 0)" 43 | if let string = String(data: data, encoding: .utf8), !string.isEmpty { 44 | error += ":\n\(string)" 45 | } 46 | return error 47 | case let .decodingError(_, error): 48 | if let error = error as? DecodingError { 49 | return error.decodingError 50 | } else { 51 | return "\(error)" 52 | } 53 | case let .encodingError(error): return "\(error)" 54 | case .handlerError: return "" 55 | case .noResponse: return "" 56 | case .genericError: return "" 57 | } 58 | } 59 | 60 | } 61 | 62 | extension RequestError: LocalizedError { 63 | 64 | public var errorDescription: String? { 65 | message 66 | } 67 | } 68 | 69 | extension RequestError: CustomStringConvertible { 70 | public var description: String { 71 | var string = name 72 | if !message.isEmpty { 73 | string += "\n\(message)" 74 | } 75 | return string.trimmingCharacters(in: .whitespacesAndNewlines) 76 | } 77 | } 78 | 79 | extension DecodingError { 80 | 81 | var context: DecodingError.Context? { 82 | switch self { 83 | 84 | case .typeMismatch(_, let context): 85 | return context 86 | case .valueNotFound(_, let context): 87 | return context 88 | case .keyNotFound(_, let context): 89 | return context 90 | case .dataCorrupted(let context): 91 | return context 92 | @unknown default: 93 | return nil 94 | } 95 | } 96 | 97 | public var decodingError: String { 98 | let codingPath: String 99 | let contextDescription: String 100 | if let context = context { 101 | codingPath = " at " + context.codingPath 102 | .map { $0.intValue.flatMap { "[\($0)]" } ?? $0.stringValue } 103 | .joined(separator: ".") 104 | .replacingOccurrences(of: ".[", with: "[") 105 | contextDescription = context.debugDescription 106 | } else { 107 | codingPath = "" 108 | contextDescription = "" 109 | } 110 | switch self { 111 | case .keyNotFound(let key, _): 112 | return "Key \"\(key.stringValue)\" not found\(codingPath)" 113 | case .typeMismatch(let type, _): 114 | return "Expected type \"\(type)\" not found\(codingPath)" 115 | case .dataCorrupted: 116 | return "\(contextDescription)\(codingPath)" 117 | case .valueNotFound(let type, _): 118 | return "Value \"\(type)\" not found\(codingPath)" 119 | default: 120 | return String(describing: self) 121 | } 122 | } 123 | 124 | } 125 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/NetworkService/RequestHandler.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public protocol RequestHandler { 5 | 6 | /// called when request is created 7 | func requestCreated(id: String, request: AnyRequest) 8 | 9 | /// validates and modifies the request. complete must be called with either .success or .fail 10 | func modifyRequest(id: String, request: AnyRequest, urlRequest: URLRequest, complete: @escaping (Result) -> Void) 11 | 12 | /// called before request is sent 13 | func requestSent(id: String, request: AnyRequest) 14 | 15 | /// called when the request gets a url response 16 | func requestResponded(id: String, request: AnyRequest, data: Data?, urlResponse: HTTPURLResponse?, error: Error?) 17 | 18 | /// called when the request completes 19 | func requestCompleted(id: String, request: AnyRequest, result: RequestResult) 20 | } 21 | 22 | public extension RequestHandler { 23 | 24 | func requestCreated(id: String, request: AnyRequest){} 25 | func modifyRequest(id: String, request: AnyRequest, urlRequest: URLRequest, complete: @escaping (Result) -> Void) { 26 | complete(.success(urlRequest)) 27 | } 28 | func requestSent(id: String, request: AnyRequest) {} 29 | func requestResponded(id: String, request: AnyRequest, data: Data?, urlResponse: HTTPURLResponse?, error: Error?) {} 30 | func requestCompleted(id: String, request: AnyRequest, result: RequestResult) {} 31 | } 32 | 33 | /// Group different RequestBehaviours together 34 | public struct RequestHandlerGroup: RequestHandler { 35 | 36 | let handlers: [RequestHandler] 37 | 38 | public init(handlers: [RequestHandler]) { 39 | self.handlers = handlers 40 | } 41 | 42 | public func requestCreated(id: String, request: AnyRequest) { 43 | handlers.forEach { 44 | $0.requestCreated(id: id, request: request) 45 | } 46 | } 47 | 48 | public func requestSent(id: String, request: AnyRequest) { 49 | handlers.forEach { 50 | $0.requestSent(id: id, request: request) 51 | } 52 | } 53 | 54 | public func modifyRequest(id: String, request: AnyRequest, urlRequest: URLRequest, complete: @escaping (Result) -> Void) { 55 | if handlers.isEmpty { 56 | complete(.success(urlRequest)) 57 | return 58 | } 59 | 60 | var count = 0 61 | var modifiedRequest = urlRequest 62 | func validateNext() { 63 | let handler = handlers[count] 64 | handler.modifyRequest(id: id, request: request, urlRequest: modifiedRequest) { result in 65 | count += 1 66 | switch result { 67 | case .success(let urlRequest): 68 | modifiedRequest = urlRequest 69 | if count == self.handlers.count { 70 | complete(.success(modifiedRequest)) 71 | } else { 72 | validateNext() 73 | } 74 | case .failure(let error): 75 | complete(.failure(error)) 76 | } 77 | } 78 | } 79 | validateNext() 80 | } 81 | 82 | public func requestResponded(id: String, request: AnyRequest, data: Data?, urlResponse: HTTPURLResponse?, error: Error?) { 83 | handlers.forEach { 84 | $0.requestResponded(id: id, request: request, data: data, urlResponse: urlResponse, error: error) 85 | } 86 | } 87 | 88 | public func requestCompleted(id: String, request: AnyRequest, result: RequestResult) { 89 | handlers.forEach { 90 | $0.requestCompleted(id: id, request: request, result: result) 91 | } 92 | } 93 | } 94 | 95 | /// Wraps a RequestHandler in an easy to use struct that can be initialized with any request 96 | public struct AnyRequestHandler { 97 | 98 | let id: String 99 | let request: AnyRequest 100 | let handler: RequestHandler 101 | 102 | public init(id: String, request: R, handler: RequestHandler) { 103 | self.id = id 104 | self.request = AnyRequest(request) 105 | self.handler = handler 106 | } 107 | 108 | func requestCreated() { 109 | handler.requestCreated(id: id, request: request) 110 | } 111 | 112 | public func requestSent() { 113 | handler.requestSent(id: id, request: request) 114 | } 115 | 116 | public func modifyRequest(_ urlRequest: URLRequest, complete: @escaping (Result) -> Void) { 117 | handler.modifyRequest(id: id, request: request, urlRequest: urlRequest, complete: complete) 118 | } 119 | 120 | public func requestResponded(data: Data?, urlResponse: HTTPURLResponse?, error: Error?) { 121 | handler.requestResponded(id: id, request: request, data: data, urlResponse: urlResponse, error: error) 122 | } 123 | 124 | public func requestCompleted(result: RequestResult) { 125 | handler.requestCompleted(id: id, request: request, result: result) 126 | } 127 | } 128 | 129 | public struct AnyRequest: Request { 130 | 131 | public typealias ResponseType = Any 132 | public var path: String 133 | public var baseURL: String 134 | public var method: HTTPMethod 135 | public var headers: [String: String] 136 | public var urlParams: [String: Any?] 137 | public var requestName: String 138 | public var validStatusCode: (Int) -> Bool 139 | public var decodeResponse: (Data, Int) throws -> ResponseType 140 | public var encodeBody: () throws -> Data? 141 | public var getURLRequest: () throws -> URLRequest 142 | public var responseType: Any.Type 143 | 144 | public init(_ request: R) { 145 | self.path = request.path 146 | self.baseURL = request.baseURL 147 | self.method = request.method 148 | self.headers = request.headers 149 | self.urlParams = request.urlParams 150 | self.requestName = request.requestName 151 | self.validStatusCode = request.validStatusCode 152 | self.decodeResponse = request.decodeResponse 153 | self.encodeBody = request.encodeBody 154 | self.getURLRequest = request.getURLRequest 155 | self.responseType = R.self 156 | } 157 | 158 | public func decodeResponse(data: Data, statusCode: Int) throws -> Any { 159 | try self.decodeResponse(data, statusCode) 160 | } 161 | 162 | } 163 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/Request/DecodableRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public protocol DecodableRequest: Request { 5 | 6 | var decoder: Decoder { get } 7 | } 8 | 9 | public extension DecodableRequest where ResponseType: Decodable { 10 | 11 | func decodeResponse(data: Data, statusCode: Int) throws -> ResponseType { 12 | return try decoder.decode(ResponseType.self, from: data) 13 | } 14 | } 15 | 16 | public protocol JSONDecodableRequest: DecodableRequest { } 17 | 18 | public extension JSONDecodableRequest { 19 | 20 | var decoder: Decoder { return JSONDecoder() } 21 | } 22 | 23 | public protocol Decoder { 24 | 25 | func decode(_ type: T.Type, from: Data) throws -> T 26 | } 27 | 28 | extension JSONDecoder: Decoder {} 29 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/Request/EncodableRequest.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | public protocol EncodableRequest: Request { 5 | 6 | associatedtype BodyType: Encodable 7 | var encoder: Encoder { get } 8 | var body: BodyType { get } 9 | } 10 | 11 | public extension EncodableRequest { 12 | 13 | func encodeBody() throws -> Data? { 14 | return try encoder.encode(body) 15 | } 16 | } 17 | 18 | public protocol JSONEncodableRequest: EncodableRequest { } 19 | 20 | public extension JSONEncodableRequest { 21 | 22 | var encoder: Encoder { return JSONEncoder() } 23 | } 24 | 25 | public protocol Encoder { 26 | 27 | func encode(_ value: T) throws -> Data 28 | } 29 | 30 | extension JSONEncoder: Encoder {} 31 | -------------------------------------------------------------------------------- /Sources/NetworkRequest/Request/Request.swift: -------------------------------------------------------------------------------- 1 | 2 | import Foundation 3 | 4 | /** 5 | A protocol to conform to for specific network requests. 6 | Most properties have defaults you only have to provide the minimum information about a request 7 | **/ 8 | public protocol Request: CustomStringConvertible { 9 | 10 | associatedtype ResponseType = Void 11 | 12 | /// the path of the request eg: /pets 13 | var path: String { get } 14 | 15 | /// defaults to an empty string. 16 | /// The baseURL from the HTTPNetworkService will also be prepended so that can be used if all the requests have the same baseURL 17 | var baseURL: String { get } 18 | 19 | /// defaults to .get 20 | var method: HTTPMethod { get } 21 | 22 | /// defaults to an empty dictionary 23 | var headers: [String: String] { get } 24 | 25 | /// defaults to an empty dictionary 26 | var urlParams: [String: Any?] { get } 27 | 28 | /// defaults to the object name 29 | var requestName: String { get } 30 | 31 | /** 32 | Whether a given status code is valid. 33 | If this returns false then a RequestError.invalidStatus code error will be returned. 34 | It can be used to return something like an enum with associated types for both success and failure responses 35 | This defaults to returning true for 2xx and 3xx responses. 36 | ***/ 37 | func validStatusCode(_ statusCode: Int) -> Bool 38 | 39 | /// How to decode a given response from data. This is provided by default in DecodableRequest and JSONDecodableRequest 40 | func decodeResponse(data: Data, statusCode: Int) throws -> ResponseType 41 | 42 | /// How to encode a body. This returns nil by default which means no body will be sent 43 | func encodeBody() throws -> Data? 44 | 45 | /// get a fully formed URLRequest. This is provided by default and uses all of the above properties to create a URLRequest 46 | func getURLRequest() throws -> URLRequest 47 | } 48 | 49 | public enum HTTPMethod: String { 50 | case get = "GET" 51 | case post = "POST" 52 | case put = "PUT" 53 | case delete = "DELETE" 54 | case patch = "PATCH" 55 | } 56 | 57 | public extension Request { 58 | 59 | var baseURL: String { return "" } 60 | var method: HTTPMethod { return .get } 61 | var headers: [String: String] { return [:] } 62 | var urlParams: [String: Any?] { return [:] } 63 | var requestName: String { return String(describing: Self.self)} 64 | 65 | func encodeBody() -> Data? { return nil } 66 | 67 | func validStatusCode(_ statusCode: Int) -> Bool { 68 | return statusCode.description.hasPrefix("2") || statusCode.description.hasPrefix("3") 69 | } 70 | 71 | func getURLRequest() throws -> URLRequest { 72 | let percentEncodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? "" 73 | let urlString = "\(baseURL)\(percentEncodedPath)" 74 | guard var urlComponents = URLComponents(string: urlString) else { 75 | fatalError("Invalid url \(urlString)") 76 | } 77 | if !urlParams.isEmpty { 78 | urlComponents.query = urlParams 79 | .compactMap { key, value -> (String, Any)? in 80 | guard let value = value else { return nil } 81 | return (key, value) 82 | } 83 | .map { "\($0)=\($1)" } 84 | .joined(separator: "&") 85 | } 86 | guard let url = urlComponents.url else { 87 | fatalError("Invalid url \(urlComponents)") 88 | } 89 | var urlRequest = URLRequest(url: url) 90 | urlRequest.httpMethod = method.rawValue 91 | if let body = try encodeBody() { 92 | urlRequest.httpBody = body 93 | } 94 | for (name, value) in headers { 95 | urlRequest.setValue(value, forHTTPHeaderField: name) 96 | } 97 | return urlRequest 98 | } 99 | 100 | var description: String { 101 | let params = self.urlParams 102 | .map { $0 } 103 | .sorted { $0.key < $1.key } 104 | .compactMap { param in 105 | if let value = param.value { 106 | return "\(param.key): \(value)" 107 | } else { 108 | return nil 109 | } 110 | } 111 | .joined(separator: ", ") 112 | return "\(requestName): \(method.rawValue) \(path)\(params.isEmpty ? "" : " \(params)")" 113 | } 114 | } 115 | 116 | // provide empty decoding for no response 117 | public extension Request where ResponseType == Void { 118 | 119 | func decodeResponse(data: Data, statusCode: Int) throws -> ResponseType { 120 | return () 121 | } 122 | } 123 | 124 | // if response is a tuple with (Data, Int) the data and status code will be returned untouched 125 | public extension Request where ResponseType == (Data, Int) { 126 | 127 | func decodeResponse(data: Data, statusCode: Int) throws -> ResponseType { 128 | return (data, statusCode) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import NetworkRequestTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += NetworkRequestTests.__allTests() 7 | 8 | XCTMain(tests) 9 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/HTTPNetworkTests.swift: -------------------------------------------------------------------------------- 1 | 2 | import XCTest 3 | import NetworkRequest 4 | 5 | class HTTPNetworkRequestTests: XCTestCase { 6 | 7 | let networkService = HTTPNetworkService(baseURL: "https://jsonplaceholder.typicode.com") 8 | 9 | func testNetworkRequest() { 10 | 11 | let request = GetPosts(userId: 2) 12 | let expectation = XCTestExpectation() 13 | networkService.makeRequest(request) { result in 14 | switch result { 15 | case .success(let posts): 16 | _ = posts.count 17 | case .failure(let error): 18 | print(error) 19 | } 20 | expectation.fulfill() 21 | } 22 | wait(for: [expectation], timeout: 60) 23 | } 24 | 25 | func testNetworkRequestEncoding() throws { 26 | 27 | let body = PostBody(userId: 4, title: "My title", body: "My body") 28 | let request = SavePost(body: body) 29 | 30 | let expectation = XCTestExpectation() 31 | networkService.makeRequest(request) { result in 32 | expectation.fulfill() 33 | } 34 | wait(for: [expectation], timeout: 60) 35 | } 36 | } 37 | 38 | fileprivate struct GetPosts: JSONDecodableRequest { 39 | typealias ResponseType = [Post] 40 | let userId: Int 41 | 42 | let path: String = "/posts" 43 | var urlParams: [String: Any?] { return ["userId": userId] } 44 | } 45 | 46 | fileprivate struct SavePost: Request, JSONEncodableRequest { 47 | 48 | var body: PostBody 49 | 50 | let method: HTTPMethod = .post 51 | let path: String = "/posts" 52 | } 53 | 54 | fileprivate struct Post: Codable { 55 | let userId: Int 56 | let id: Int 57 | let title: String 58 | let body: String 59 | } 60 | 61 | fileprivate struct PostBody: Codable { 62 | let userId: Int 63 | let title: String 64 | let body: String 65 | } 66 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/MockNetworkTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import NetworkRequest 3 | 4 | class MockNetworkTests: XCTestCase { 5 | 6 | let networkService = MockNetworkService() 7 | 8 | override func setUp() { 9 | networkService.unmockAll() 10 | } 11 | 12 | func testDataMock() throws { 13 | 14 | let request = ItemRequest() 15 | 16 | let item = Item(name: "hello") 17 | let jsonEncoder = JSONEncoder() 18 | let data = try jsonEncoder.encode(item) 19 | networkService.mock(request: request, data: data) 20 | 21 | assertNetworkResponse(service: networkService, request: request, expectedResult: .success(item)) 22 | assertNetworkResponse(service: networkService, request: ItemRequest2(), expectedResult: .failure(.noResponse)) 23 | } 24 | 25 | func testSuccessMock() throws { 26 | 27 | let request = ItemRequest() 28 | let item = Item(name: "test") 29 | networkService.mock(request: request, result: .success(item)) 30 | 31 | assertNetworkResponse(service: networkService, request: request, expectedResult: .success(item)) 32 | assertNetworkResponse(service: networkService, request: ItemRequest2(), expectedResult: .failure(.noResponse)) 33 | } 34 | 35 | func testFailureMock() throws { 36 | 37 | let request = ItemRequest() 38 | networkService.mock(request: request, result: .failure(.apiError(Data("test".utf8), HTTPURLResponse()))) 39 | 40 | assertNetworkResponse(service: networkService, request: request, expectedResult: .failure(.apiError(Data("test".utf8), HTTPURLResponse()))) 41 | assertNetworkResponse(service: networkService, request: ItemRequest2(), expectedResult: .failure(.noResponse)) 42 | } 43 | 44 | func testDynamicMock() throws { 45 | 46 | let item = Item(name: "test") 47 | networkService.mock { (request: ItemRequest) in 48 | if request.name == "test" { 49 | return .success(item) 50 | } else { 51 | return nil 52 | } 53 | } 54 | 55 | assertNetworkResponse(service: networkService, request: ItemRequest(name: "test"), expectedResult: .success(item)) 56 | assertNetworkResponse(service: networkService, request: ItemRequest(name: "invalid"), expectedResult: .failure(.noResponse)) 57 | assertNetworkResponse(service: networkService, request: ItemRequest2(name: "test"), expectedResult: .failure(.noResponse)) 58 | } 59 | } 60 | 61 | fileprivate struct ItemRequest: JSONDecodableRequest { 62 | 63 | typealias ResponseType = Item 64 | var name: String? = nil 65 | let path: String = "/item" 66 | } 67 | 68 | fileprivate struct ItemRequest2: JSONDecodableRequest { 69 | 70 | typealias ResponseType = Item 71 | var name: String? = nil 72 | let path: String = "/item2" 73 | } 74 | 75 | fileprivate struct Item: Codable, Equatable { 76 | let name: String 77 | } 78 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/NetworkServiceGroupTests.swift: -------------------------------------------------------------------------------- 1 | 2 | import XCTest 3 | import NetworkRequest 4 | 5 | class NetworkGroupTests: XCTestCase { 6 | 7 | func testEmptyGroup() throws { 8 | let networkService = NetworkServiceGroup(services: []) 9 | assertNetworkResponse(service: networkService, request: ItemRequest(), expectedResult: .failure(.noResponse)) 10 | } 11 | 12 | func testMultiGroup() throws { 13 | let mock1 = MockNetworkService() 14 | let mock2 = MockNetworkService() 15 | 16 | let request = ItemRequest() 17 | let item = Item(name: "test") 18 | mock2.mock(request: request, result: .success(item)) 19 | 20 | var networkService: NetworkServiceGroup 21 | 22 | networkService = NetworkServiceGroup(services: [mock1, mock2]) 23 | assertNetworkResponse(service: networkService, request: request, expectedResult: .success(item)) 24 | 25 | networkService = NetworkServiceGroup(services: [mock2, mock1]) 26 | assertNetworkResponse(service: networkService, request: request, expectedResult: .success(item)) 27 | } 28 | } 29 | 30 | fileprivate struct ItemRequest: JSONDecodableRequest { 31 | 32 | typealias ResponseType = Item 33 | var name: String? = nil 34 | let path: String = "/item" 35 | } 36 | 37 | fileprivate struct Item: Codable, Equatable { 38 | let name: String 39 | } 40 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/RequestHandlerTests.swift: -------------------------------------------------------------------------------- 1 | 2 | import XCTest 3 | import NetworkRequest 4 | 5 | class RequestHandlerTests: XCTestCase { 6 | 7 | let networkService = HTTPNetworkService(baseURL: "https://jsonplaceholder.typicode.com") 8 | 9 | func testHandler() throws { 10 | 11 | let handler = MockHandler { urlRequest in 12 | var urlRequest = urlRequest 13 | urlRequest.addValue("1", forHTTPHeaderField: "one") 14 | return .success(urlRequest) 15 | } 16 | 17 | let networkService = HTTPNetworkService(baseURL: "https://jsonplaceholder.typicode.com", requestHandlers: [handler]) 18 | 19 | let expectation = XCTestExpectation() 20 | let request = GetPosts(userId: 1) 21 | networkService.makeRequest(request) { result in 22 | expectation.fulfill() 23 | } 24 | wait(for: [expectation], timeout: 5) 25 | 26 | XCTAssertTrue(handler.beforeSentCalled) 27 | XCTAssertTrue(handler.requestResponsedCalled) 28 | 29 | let modified = try unwrap(handler.modified) 30 | let urlRequest = try modified.get() 31 | XCTAssertEqual(urlRequest.allHTTPHeaderFields, ["one": "1"]) 32 | 33 | let completed = try unwrap(handler.completed) 34 | let result = try completed.get() 35 | _ = try unwrap(result as? [Post]) 36 | } 37 | 38 | func testHandlerGroup() throws { 39 | 40 | let handler1 = MockHandler { urlRequest in 41 | var urlRequest = urlRequest 42 | urlRequest.addValue("1", forHTTPHeaderField: "one") 43 | return .success(urlRequest) 44 | } 45 | 46 | let handler2 = MockHandler { urlRequest in 47 | var urlRequest = urlRequest 48 | urlRequest.addValue("2", forHTTPHeaderField: "two") 49 | return .success(urlRequest) 50 | } 51 | 52 | let group = RequestHandlerGroup(handlers: [handler1, handler2]) 53 | let request = GetPosts(userId: 1) 54 | let requestHandler = AnyRequestHandler(id: "1", request: request, handler: group) 55 | 56 | requestHandler.requestSent() 57 | requestHandler.requestCompleted(result: .success(2)) 58 | var urlRequest: URLRequest? 59 | requestHandler.modifyRequest(try request.getURLRequest()) { result in 60 | urlRequest = try! result.get() 61 | } 62 | 63 | XCTAssertTrue(handler1.beforeSentCalled) 64 | XCTAssertTrue(handler2.beforeSentCalled) 65 | XCTAssertNotNil(try handler1.completed?.get()) 66 | XCTAssertNotNil(try handler1.completed?.get()) 67 | XCTAssertNotNil(try handler1.modified?.get()) 68 | XCTAssertNotNil(try handler2.modified?.get()) 69 | 70 | XCTAssertEqual(urlRequest?.allHTTPHeaderFields, ["one": "1", "two": "2"]) 71 | } 72 | 73 | func testHandlerGroupFailure() throws { 74 | 75 | let modifiedError = StringError("failed") 76 | let handler1 = MockHandler { urlRequest in 77 | .failure(modifiedError) 78 | } 79 | 80 | let handler2 = MockHandler { urlRequest in 81 | var urlRequest = urlRequest 82 | urlRequest.addValue("2", forHTTPHeaderField: "two") 83 | return .success(urlRequest) 84 | } 85 | 86 | let group = RequestHandlerGroup(handlers: [handler1, handler2]) 87 | let request = GetPosts(userId: 1) 88 | let requestHandler = AnyRequestHandler(id: "1", request: request, handler: group) 89 | 90 | requestHandler.requestCompleted(result: .failure(.handlerError(modifiedError))) 91 | var errorString: String? 92 | requestHandler.modifyRequest(try request.getURLRequest()) { result in 93 | if case .failure(let error) = result { 94 | errorString = String(describing: error) 95 | } 96 | } 97 | 98 | XCTAssertFalse(handler1.beforeSentCalled) 99 | XCTAssertFalse(handler2.beforeSentCalled) 100 | XCTAssertThrowsError(try handler1.modified?.get()) 101 | XCTAssertNil(handler2.modified) 102 | XCTAssertThrowsError(try handler1.completed?.get()) 103 | XCTAssertThrowsError(try handler1.completed?.get()) 104 | 105 | XCTAssertEqual(errorString, "failed") 106 | } 107 | 108 | func testHandlerFailure() throws { 109 | 110 | let modifiedError = StringError("failed") 111 | let handler1 = MockHandler { urlRequest in 112 | .failure(modifiedError) 113 | } 114 | 115 | let handler2 = MockHandler { urlRequest in 116 | var urlRequest = urlRequest 117 | urlRequest.addValue("2", forHTTPHeaderField: "two") 118 | return .success(urlRequest) 119 | } 120 | 121 | let networkService = HTTPNetworkService(baseURL: "https://jsonplaceholder.typicode.com", requestHandlers: [handler1, handler2]) 122 | 123 | let expectation = XCTestExpectation() 124 | let request = GetPosts(userId: 1) 125 | var requestResult: RequestResult<[Post]>! 126 | networkService.makeRequest(request) { result in 127 | requestResult = result 128 | expectation.fulfill() 129 | } 130 | wait(for: [expectation], timeout: 5) 131 | 132 | switch requestResult { 133 | case .success: 134 | XCTFail("Should have failed") 135 | case .failure(let error): 136 | XCTAssertEqual(error.description, "failed") 137 | case .none: 138 | XCTFail("Should have been set") 139 | } 140 | } 141 | } 142 | 143 | class MockHandler: RequestHandler { 144 | 145 | let modifier: (URLRequest) -> Result 146 | 147 | var modified: Result? 148 | var beforeSentCalled: Bool = false 149 | var requestResponsedCalled: Bool = false 150 | var completed: RequestResult? 151 | 152 | init(modifier: @escaping (URLRequest) -> Result) { 153 | self.modifier = modifier 154 | } 155 | 156 | func modifyRequest(id: String, request: AnyRequest, urlRequest: URLRequest, complete: @escaping (Result) -> Void) { 157 | let result = modifier(urlRequest) 158 | modified = result 159 | complete(result) 160 | } 161 | func requestSent(id: String, request: AnyRequest) { 162 | beforeSentCalled = true 163 | } 164 | 165 | func requestCompleted(id: String, request: AnyRequest, result: RequestResult) { 166 | completed = result 167 | } 168 | 169 | func requestResponded(id: String, request: AnyRequest, data: Data?, urlResponse: HTTPURLResponse?, error: Error?) { 170 | requestResponsedCalled = true 171 | } 172 | } 173 | 174 | fileprivate struct GetPosts: JSONDecodableRequest { 175 | typealias ResponseType = [Post] 176 | let userId: Int 177 | 178 | let path: String = "/posts" 179 | var urlParams: [String: Any?] { return ["userId": userId] } 180 | } 181 | 182 | fileprivate struct Post: Codable { 183 | let userId: Int 184 | let id: Int 185 | let title: String 186 | let body: String 187 | } 188 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/TestHelpers.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import NetworkRequest 3 | 4 | func assertNetworkResponse(service: NetworkService, request: R, expectedResult: RequestResult, file: StaticString = #file, line: UInt = #line) where R.ResponseType: Equatable { 5 | 6 | var requestResult: RequestResult! 7 | service.makeRequest(request) { result in 8 | requestResult = result 9 | } 10 | guard let result = requestResult else { 11 | XCTFail("Request didn't return", file: file, line: line) 12 | return 13 | } 14 | 15 | switch (result, expectedResult) { 16 | case (.success(let value), .success(let expectedValue)): 17 | XCTAssertEqual(value, expectedValue, file: file, line: line) 18 | case (.failure(let error), .failure(let expectedError)): 19 | XCTAssertEqual(error.description, expectedError.description, file: file, line: line) 20 | default: 21 | XCTFail("Result didn't match. Recieved \(result) but expected \(expectedResult)", file: file, line: line) 22 | } 23 | } 24 | 25 | func unwrap(_ value: T?, file: StaticString = #file, line: UInt = #line) throws -> T { 26 | if let value = value { 27 | return value 28 | } else { 29 | let error = "Expected non-nil value of \(T.self)" 30 | XCTFail(error, file: file, line: line) 31 | throw StringError(error) 32 | } 33 | } 34 | 35 | struct StringError: Error, CustomStringConvertible { 36 | 37 | public let string: String 38 | 39 | public init(_ string: String) { 40 | self.string = string 41 | } 42 | 43 | public var description: String { string } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/NetworkRequestTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | #if !canImport(ObjectiveC) 2 | import XCTest 3 | 4 | extension HTTPNetworkRequest { 5 | // DO NOT MODIFY: This is autogenerated, use: 6 | // `swift test --generate-linuxmain` 7 | // to regenerate. 8 | static let __allTests__HTTPNetworkRequest = [ 9 | ("testNetworkRequest", testNetworkRequest), 10 | ("testNetworkRequestEncoding", testNetworkRequestEncoding), 11 | ] 12 | } 13 | 14 | extension MockNetworkTests { 15 | // DO NOT MODIFY: This is autogenerated, use: 16 | // `swift test --generate-linuxmain` 17 | // to regenerate. 18 | static let __allTests__MockNetworkTests = [ 19 | ("testDataMock", testDataMock), 20 | ("testDynamicMock", testDynamicMock), 21 | ("testFailureMock", testFailureMock), 22 | ("testSuccessMock", testSuccessMock), 23 | ] 24 | } 25 | 26 | extension NetworkGroupTests { 27 | // DO NOT MODIFY: This is autogenerated, use: 28 | // `swift test --generate-linuxmain` 29 | // to regenerate. 30 | static let __allTests__NetworkGroupTests = [ 31 | ("testEmptyGroup", testEmptyGroup), 32 | ("testMultiGroup", testMultiGroup), 33 | ] 34 | } 35 | 36 | public func __allTests() -> [XCTestCaseEntry] { 37 | return [ 38 | testCase(HTTPNetworkRequest.__allTests__HTTPNetworkRequest), 39 | testCase(MockNetworkTests.__allTests__MockNetworkTests), 40 | testCase(NetworkGroupTests.__allTests__NetworkGroupTests), 41 | ] 42 | } 43 | #endif 44 | --------------------------------------------------------------------------------