├── .gitignore ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── TinyNetworking │ └── Endpoint.swift └── Tests ├── LinuxMain.swift └── TinyNetworkingTests ├── TestFixtures.swift ├── TinyHTTPStubURLProtocol.swift ├── TinyNetworkingTests.swift ├── URLSessionIntegrationTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 objc.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TinyNetworking", 8 | products: [ 9 | .library( 10 | name: "TinyNetworking", 11 | targets: ["TinyNetworking"]), 12 | ], 13 | dependencies: [ 14 | ], 15 | targets: [ 16 | .target( 17 | name: "TinyNetworking", 18 | dependencies: []), 19 | .testTarget( 20 | name: "TinyNetworkingTests", 21 | dependencies: ["TinyNetworking"]), 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TinyNetworking 2 | 3 | This package contains a tiny networking library. It provides a struct `Endpoint`, which combines a URL request and a way to parse responses for that request. Because `Endpoint` is generic over the parse result, it provides a type-safe way to use HTTP endpoints. 4 | 5 | Here are some examples: 6 | 7 | ## A Simple Endpoint 8 | 9 | This is an endpoint that represents a user's data (note that there are more fields in the JSON, left out for brevity): 10 | 11 | ```swift 12 | struct User: Codable { 13 | var name: String 14 | var location: String? 15 | } 16 | 17 | func userInfo(login: String) -> Endpoint { 18 | return Endpoint(json: .get, url: URL(string: "https://api.github.com/users/\(login)")!) 19 | } 20 | 21 | let sample = userInfo(login: "objcio") 22 | ``` 23 | 24 | The code above is just a description of an endpoint, it does not load anything. `sample` is a simple struct, which you can inspect (for example, in a unit test). 25 | 26 | Here's how you can load an endpoint. The `result` is of type `Result`. 27 | 28 | ```swift 29 | URLSession.shared.load(endpoint) { result in 30 | print(result) 31 | } 32 | ``` 33 | 34 | Alternatively, you can use the async/await option. 35 | ```swift 36 | let result = try await URLSession.shared.load(endpoint) 37 | ``` 38 | 39 | ## Authenticated Endpoints 40 | 41 | Here's an example of how you can have authenticated endpoints. You initialize the `Mailchimp` struct with an API key, and use that to compute an `authHeader`. You can then use the `authHeader` when you create endpoints. 42 | 43 | ```swift 44 | struct Mailchimp { 45 | let base = URL(string: "https://us7.api.mailchimp.com/3.0/")! 46 | var apiKey = env.mailchimpApiKey 47 | var authHeader: [String: String] { 48 | ["Authorization": "Basic " + "anystring:\(apiKey)".base64Encoded] 49 | } 50 | 51 | func addContent(for episode: Episode, toCampaign campaignId: String) -> Endpoint<()> { 52 | struct Edit: Codable { 53 | var plain_text: String 54 | var html: String 55 | } 56 | let body = Edit(plain_text: plainText(episode), html: html(episode)) 57 | let url = base.appendingPathComponent("campaigns/\(campaignId)/content") 58 | return Endpoint<()>(json: .put, url: url, body: body, headers: authHeader) 59 | } 60 | } 61 | ``` 62 | 63 | ## Custom Parsing 64 | 65 | The JSON encoding and decoding are added as conditional extensions on top of the Codable infrastructure. However, `Endpoint` itself is not at all tied to that. Here's the type of the parsing function: 66 | 67 | ``` 68 | var parse: (Data?, URLResponse?) -> Result 69 | ``` 70 | 71 | Having `Data` as the input means that you can write our own functionality on top. For example, here's a resource that parses images: 72 | 73 | ```swift 74 | struct ImageError: Error {} 75 | 76 | extension Endpoint where A == UIImage { 77 | init(imageURL: URL) { 78 | self = Endpoint(.get, url: imageURL) { data in 79 | Result { 80 | guard let d = data, let i = UIImage(data: d) else { throw ImageError() } 81 | return i 82 | } 83 | } 84 | } 85 | } 86 | ``` 87 | 88 | You can also write extensions that do custom JSON serialization, or parse XML, or another format. 89 | 90 | ## Testing Endpoints 91 | 92 | Because an `Endpoint` is a plain struct, it's easy to test synchronously without a network connection. For example, you can test the image endpoint like this: 93 | 94 | ```swift 95 | XCTAssertThrows(try Endpoint(imageURL: someURL).parse(nil, nil).get()) 96 | XCTAssertThrows(try Endpoint(imageURL: someURL).parse(invalidData, nil).get()) 97 | XCTAssertNoThrow(try Endpoint(imageURL: someURL).parse(validData, nil).get()) 98 | ``` 99 | 100 | ## Combine 101 | 102 | Hsieh Min Che created a library that adds Combine endpoints to this library: https://github.com/Hsieh-1989/CombinedEndpoint 103 | 104 | ## More Examples 105 | 106 | - In the [Swift Talk](https://talk.objc.io) backend, this is used to wrap [third-party services](https://github.com/objcio/swift-talk-backend/tree/master/Sources/SwiftTalkServerLib/ThirdPartyServices). 107 | 108 | ## More Documentation 109 | 110 | The design and implementation of this library is covered extensively on [Swift Talk](http://talk.objc.io/). There's a collection with all the relevant episodes: 111 | 112 | **[Networking](https://talk.objc.io/collections/networking)** 113 | 114 | [](https://talk.objc.io/collections/networking) 115 | 116 | -------------------------------------------------------------------------------- /Sources/TinyNetworking/Endpoint.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | #if canImport(FoundationNetworking) 3 | import FoundationNetworking 4 | #endif 5 | 6 | /// Built-in Content Types 7 | public enum ContentType: String { 8 | case json = "application/json" 9 | case xml = "application/xml" 10 | case urlencoded = "application/x-www-form-urlencoded" 11 | } 12 | 13 | /// Returns `true` if `code` is in the 200..<300 range. 14 | public func expected200to300(_ code: Int) -> Bool { 15 | return code >= 200 && code < 300 16 | } 17 | 18 | /// This describes an endpoint returning `A` values. It contains both a `URLRequest` and a way to parse the response. 19 | public struct Endpoint { 20 | 21 | /// The HTTP Method 22 | public enum Method: String { 23 | case get = "GET" 24 | case post = "POST" 25 | case put = "PUT" 26 | case patch = "PATCH" 27 | case delete = "DELETE" 28 | } 29 | 30 | /// The request for this endpoint 31 | public var request: URLRequest 32 | 33 | /// This is used to (try to) parse a response into an `A`. 34 | var parse: (Data?, URLResponse?) -> Result 35 | 36 | /// This is used to check the status code of a response. 37 | var expectedStatusCode: (Int) -> Bool = expected200to300 38 | 39 | /// Transforms the result 40 | public func map(_ f: @escaping (A) -> B) -> Endpoint { 41 | return Endpoint(request: request, expectedStatusCode: expectedStatusCode, parse: { value, response in 42 | self.parse(value, response).map(f) 43 | }) 44 | } 45 | 46 | /// Transforms the result 47 | public func compactMap(_ transform: @escaping (A) -> Result) -> Endpoint { 48 | return Endpoint(request: request, expectedStatusCode: expectedStatusCode, parse: { data, response in 49 | self.parse(data, response).flatMap(transform) 50 | }) 51 | } 52 | 53 | /// Create a new Endpoint. 54 | /// 55 | /// - Parameters: 56 | /// - method: the HTTP method 57 | /// - url: the endpoint's URL 58 | /// - accept: the content type for the `Accept` header 59 | /// - contentType: the content type for the `Content-Type` header 60 | /// - body: the body of the request. 61 | /// - headers: additional headers for the request 62 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 63 | /// - timeOutInterval: the timeout interval for his request 64 | /// - query: query parameters to append to the url 65 | /// - parse: this converts a response into an `A`. 66 | public init(_ method: Method, url: URL, accept: ContentType? = nil, contentType: ContentType? = nil, body: Data? = nil, headers: [String:String] = [:], expectedStatusCode: @escaping (Int) -> Bool = expected200to300, timeOutInterval: TimeInterval = 10, query: [String:String] = [:], parse: @escaping (Data?, URLResponse?) -> Result) { 67 | var requestUrl : URL 68 | if query.isEmpty { 69 | requestUrl = url 70 | } else { 71 | var comps = URLComponents(url: url, resolvingAgainstBaseURL: true)! 72 | comps.queryItems = comps.queryItems ?? [] 73 | comps.queryItems!.append(contentsOf: query.map { URLQueryItem(name: $0.0, value: $0.1) }) 74 | requestUrl = comps.url! 75 | } 76 | request = URLRequest(url: requestUrl) 77 | if let a = accept { 78 | request.setValue(a.rawValue, forHTTPHeaderField: "Accept") 79 | } 80 | if let ct = contentType { 81 | request.setValue(ct.rawValue, forHTTPHeaderField: "Content-Type") 82 | } 83 | for (key, value) in headers { 84 | request.setValue(value, forHTTPHeaderField: key) 85 | } 86 | request.timeoutInterval = timeOutInterval 87 | request.httpMethod = method.rawValue 88 | 89 | // body *needs* to be the last property that we set, because of this bug: https://bugs.swift.org/browse/SR-6687 90 | request.httpBody = body 91 | 92 | self.expectedStatusCode = expectedStatusCode 93 | self.parse = parse 94 | } 95 | 96 | 97 | /// Creates a new Endpoint from a request 98 | /// 99 | /// - Parameters: 100 | /// - request: the URL request 101 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 102 | /// - parse: this converts a response into an `A`. 103 | public init(request: URLRequest, expectedStatusCode: @escaping (Int) -> Bool = expected200to300, parse: @escaping (Data?, URLResponse?) -> Result) { 104 | self.request = request 105 | self.expectedStatusCode = expectedStatusCode 106 | self.parse = parse 107 | } 108 | } 109 | 110 | // MARK: - CustomStringConvertible 111 | extension Endpoint: CustomStringConvertible { 112 | public var description: String { 113 | let data = request.httpBody ?? Data() 114 | return "\(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "") \(String(data: data, encoding: .utf8) ?? "")" 115 | } 116 | } 117 | 118 | // MARK: - where A == () 119 | extension Endpoint where A == () { 120 | /// Creates a new endpoint without a parse function. 121 | /// 122 | /// - Parameters: 123 | /// - method: the HTTP method 124 | /// - url: the endpoint's URL 125 | /// - accept: the content type for the `Accept` header 126 | /// - contentType: the content type for the `Content-Type` header 127 | /// - body: the body of the request. 128 | /// - headers: additional headers for the request 129 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 130 | /// - timeOutInterval: the timeout interval for his request 131 | /// - query: query parameters to append to the url 132 | public init(_ method: Method, url: URL, accept: ContentType? = nil, contentType: ContentType? = nil, body: Data? = nil, headers: [String:String] = [:], expectedStatusCode: @escaping (Int) -> Bool = expected200to300, timeOutInterval: TimeInterval = 10, query: [String:String] = [:]) { 133 | self.init(method, url: url, accept: accept, contentType: contentType, body: body, headers: headers, expectedStatusCode: expectedStatusCode, timeOutInterval: timeOutInterval, query: query, parse: { _, _ in .success(()) }) 134 | } 135 | 136 | /// Creates a new endpoint without a parse function. 137 | /// 138 | /// - Parameters: 139 | /// - method: the HTTP method 140 | /// - json: the HTTP method 141 | /// - url: the endpoint's URL 142 | /// - accept: the content type for the `Accept` header 143 | /// - body: the body of the request. This gets encoded using a default `JSONEncoder` instance. 144 | /// - headers: additional headers for the request 145 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 146 | /// - timeOutInterval: the timeout interval for his request 147 | /// - query: query parameters to append to the url 148 | /// - encoder: the encoder that's used for encoding `A`s. 149 | public init(json method: Method, url: URL, accept: ContentType? = .json, body: B, headers: [String:String] = [:], expectedStatusCode: @escaping (Int) -> Bool = expected200to300, timeOutInterval: TimeInterval = 10, query: [String:String] = [:], encoder: JSONEncoder = JSONEncoder()) { 150 | let b = try! encoder.encode(body) 151 | self.init(method, url: url, accept: accept, contentType: .json, body: b, headers: headers, expectedStatusCode: expectedStatusCode, timeOutInterval: timeOutInterval, query: query, parse: { _, _ in .success(()) }) 152 | } 153 | } 154 | 155 | // MARK: - where A: Decodable 156 | extension Endpoint where A: Decodable { 157 | /// Creates a new endpoint. 158 | /// 159 | /// - Parameters: 160 | /// - method: the HTTP method 161 | /// - url: the endpoint's URL 162 | /// - accept: the content type for the `Accept` header 163 | /// - headers: additional headers for the request 164 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 165 | /// - timeOutInterval: the timeout interval for his request 166 | /// - query: query parameters to append to the url 167 | /// - decoder: the decoder that's used for decoding `A`s. 168 | public init(json method: Method, url: URL, accept: ContentType = .json, headers: [String: String] = [:], expectedStatusCode: @escaping (Int) -> Bool = expected200to300, timeOutInterval: TimeInterval = 10, query: [String: String] = [:], decoder: JSONDecoder = JSONDecoder()) { 169 | self.init(method, url: url, accept: accept, body: nil, headers: headers, expectedStatusCode: expectedStatusCode, timeOutInterval: timeOutInterval, query: query) { data, _ in 170 | return Result { 171 | guard let dat = data else { throw NoDataError() } 172 | return try decoder.decode(A.self, from: dat) 173 | } 174 | } 175 | } 176 | 177 | /// Creates a new endpoint. 178 | /// 179 | /// - Parameters: 180 | /// - method: the HTTP method 181 | /// - url: the endpoint's URL 182 | /// - accept: the content type for the `Accept` header 183 | /// - body: the body of the request. This is encoded using a default encoder. 184 | /// - headers: additional headers for the request 185 | /// - expectedStatusCode: the status code that's expected. If this returns false for a given status code, parsing fails. 186 | /// - timeOutInterval: the timeout interval for his request 187 | /// - query: query parameters to append to the url 188 | /// - decoder: the decoder that's used for decoding `A`s. 189 | /// - encoder: the encoder that's used for encoding `A`s. 190 | public init(json method: Method, url: URL, accept: ContentType = .json, body: B? = nil, headers: [String: String] = [:], expectedStatusCode: @escaping (Int) -> Bool = expected200to300, timeOutInterval: TimeInterval = 10, query: [String: String] = [:], decoder: JSONDecoder = JSONDecoder(), encoder: JSONEncoder = JSONEncoder()) { 191 | let b = body.map { try! encoder.encode($0) } 192 | self.init(method, url: url, accept: accept, contentType: .json, body: b, headers: headers, expectedStatusCode: expectedStatusCode, timeOutInterval: timeOutInterval, query: query) { data, _ in 193 | return Result { 194 | guard let dat = data else { throw NoDataError() } 195 | return try decoder.decode(A.self, from: dat) 196 | } 197 | } 198 | } 199 | } 200 | 201 | /// Signals that a response's data was unexpectedly nil. 202 | public struct NoDataError: Error { 203 | public init() { } 204 | } 205 | 206 | /// An unknown error 207 | public struct UnknownError: Error { 208 | public init() { } 209 | } 210 | 211 | /// Signals that a response's status code was wrong. 212 | public struct WrongStatusCodeError: Error { 213 | public let statusCode: Int 214 | public let response: HTTPURLResponse? 215 | public let responseBody: Data? 216 | public init(statusCode: Int, response: HTTPURLResponse?, responseBody: Data?) { 217 | self.statusCode = statusCode 218 | self.response = response 219 | self.responseBody = responseBody 220 | } 221 | } 222 | 223 | extension URLSession { 224 | @discardableResult 225 | /// Loads an endpoint by creating (and directly resuming) a data task. 226 | /// 227 | /// - Parameters: 228 | /// - e: The endpoint. 229 | /// - onComplete: The completion handler. 230 | /// - Returns: The data task. 231 | public func load(_ e: Endpoint, onComplete: @escaping (Result) -> ()) -> URLSessionDataTask { 232 | let r = e.request 233 | let task = dataTask(with: r, completionHandler: { data, resp, err in 234 | if let err = err { 235 | onComplete(.failure(err)) 236 | return 237 | } 238 | 239 | guard let h = resp as? HTTPURLResponse else { 240 | onComplete(.failure(UnknownError())) 241 | return 242 | } 243 | 244 | guard e.expectedStatusCode(h.statusCode) else { 245 | onComplete(.failure(WrongStatusCodeError(statusCode: h.statusCode, response: h, responseBody: data))) 246 | return 247 | } 248 | 249 | onComplete(e.parse(data,resp)) 250 | }) 251 | task.resume() 252 | return task 253 | } 254 | } 255 | 256 | #if canImport(Combine) 257 | import Combine 258 | 259 | @available(iOS 13, macOS 10.15, watchOS 6, tvOS 13, *) 260 | extension URLSession { 261 | /// Returns a publisher that wraps a URL session data task for a given Endpoint. 262 | /// 263 | /// - Parameters: 264 | /// - e: The endpoint. 265 | /// - Returns: The publisher of a dataTask. 266 | public func load(_ e: Endpoint) -> AnyPublisher { 267 | let r = e.request 268 | return dataTaskPublisher(for: r) 269 | .tryMap { data, resp in 270 | guard let h = resp as? HTTPURLResponse else { 271 | throw UnknownError() 272 | } 273 | 274 | guard e.expectedStatusCode(h.statusCode) else { 275 | throw WrongStatusCodeError(statusCode: h.statusCode, response: h, responseBody: data) 276 | } 277 | 278 | return try e.parse(data, resp).get() 279 | } 280 | .eraseToAnyPublisher() 281 | } 282 | } 283 | #endif 284 | 285 | #if swift(>=5.5) && canImport(Darwin) 286 | @available(iOS 15, macOS 12.0, watchOS 8, tvOS 15, *) 287 | public extension URLSession { 288 | /// Loads the contents of a `Endpoint` and delivers the data asynchronously. 289 | /// - Returns: The parsed `A` value specified in `Endpoint` 290 | func load(_ e: Endpoint) async throws -> A { 291 | let request = e.request 292 | let (data, resp) = try await self.data(for: request) 293 | guard let h = resp as? HTTPURLResponse else { 294 | throw UnknownError() 295 | } 296 | guard e.expectedStatusCode(h.statusCode) else { 297 | throw WrongStatusCodeError(statusCode: h.statusCode, response: h, responseBody: data) 298 | } 299 | return try e.parse(data, resp).get() 300 | } 301 | } 302 | #endif 303 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import TinyNetworkingTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += TinyNetworkingTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/TinyNetworkingTests/TestFixtures.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Person: Codable, Equatable { 4 | var name: String 5 | } 6 | 7 | let exampleJSON = """ 8 | [ 9 | { 10 | "name": "Alice" 11 | }, 12 | { 13 | "name": "Bob" 14 | } 15 | ] 16 | """ 17 | -------------------------------------------------------------------------------- /Tests/TinyNetworkingTests/TinyHTTPStubURLProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct StubbedResponse { 4 | let response: HTTPURLResponse 5 | let data: Data 6 | } 7 | 8 | class TinyHTTPStubURLProtocol: URLProtocol { 9 | static var urls = [URL: StubbedResponse]() 10 | 11 | override class func canInit(with request: URLRequest) -> Bool { 12 | guard let url = request.url else { return false } 13 | return urls.keys.contains(url) 14 | } 15 | 16 | override class func canonicalRequest(for request: URLRequest) -> URLRequest { 17 | return request 18 | } 19 | 20 | override class func requestIsCacheEquivalent(_: URLRequest, to _: URLRequest) -> Bool { 21 | return false 22 | } 23 | 24 | override func startLoading() { 25 | guard let client = client, let url = request.url, let stub = TinyHTTPStubURLProtocol.urls[url] else { 26 | fatalError() 27 | } 28 | 29 | client.urlProtocol(self, didReceive: stub.response, cacheStoragePolicy: .notAllowed) 30 | client.urlProtocol(self, didLoad: stub.data) 31 | client.urlProtocolDidFinishLoading(self) 32 | } 33 | 34 | override func stopLoading() {} 35 | } 36 | -------------------------------------------------------------------------------- /Tests/TinyNetworkingTests/TinyNetworkingTests.swift: -------------------------------------------------------------------------------- 1 | @testable import TinyNetworking 2 | import XCTest 3 | 4 | final class TinyNetworkingTests: XCTestCase { 5 | func testUrlWithoutParams() { 6 | let url = URL(string: "http://www.example.com/example.json")! 7 | let endpoint = Endpoint<[String]>(json: .get, url: url) 8 | XCTAssertEqual(url, endpoint.request.url) 9 | } 10 | 11 | func testUrlWithParams() { 12 | let url = URL(string: "http://www.example.com/example.json")! 13 | let endpoint = Endpoint<[String]>(json: .get, url: url, query: ["foo": "bar bar"]) 14 | XCTAssertEqual(URL(string: "http://www.example.com/example.json?foo=bar%20bar")!, endpoint.request.url) 15 | } 16 | 17 | func testUrlAdditionalParams() { 18 | let url = URL(string: "http://www.example.com/example.json?abc=def")! 19 | let endpoint = Endpoint<[String]>(json: .get, url: url, query: ["foo": "bar bar"]) 20 | XCTAssertEqual(URL(string: "http://www.example.com/example.json?abc=def&foo=bar%20bar")!, endpoint.request.url) 21 | } 22 | 23 | static var allTests = [ 24 | ("testUrlWithoutParams", testUrlWithoutParams), 25 | ("testUrlWithParams", testUrlWithParams), 26 | ("testUrlAdditionalParams", testUrlAdditionalParams), 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /Tests/TinyNetworkingTests/URLSessionIntegrationTests.swift: -------------------------------------------------------------------------------- 1 | @testable import TinyNetworking 2 | import XCTest 3 | 4 | final class URLSessionIntegrationTests: XCTestCase { 5 | override func setUp() { 6 | super.setUp() 7 | URLProtocol.registerClass(TinyHTTPStubURLProtocol.self) 8 | } 9 | 10 | override func tearDown() { 11 | super.tearDown() 12 | URLProtocol.unregisterClass(TinyHTTPStubURLProtocol.self) 13 | } 14 | 15 | func testDataTaskRequest() throws { 16 | let url = URL(string: "http://www.example.com/example.json")! 17 | 18 | TinyHTTPStubURLProtocol.urls[url] = StubbedResponse(response: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, data: exampleJSON.data(using: .utf8)!) 19 | 20 | let endpoint = Endpoint<[Person]>(json: .get, url: url) 21 | let expectation = self.expectation(description: "Stubbed network call") 22 | 23 | let task = URLSession.shared.load(endpoint) { result in 24 | switch result { 25 | case let .success(payload): 26 | XCTAssertEqual([Person(name: "Alice"), Person(name: "Bob")], payload) 27 | expectation.fulfill() 28 | case let .failure(error): 29 | XCTFail(String(describing: error)) 30 | } 31 | } 32 | 33 | task.resume() 34 | 35 | wait(for: [expectation], timeout: 1) 36 | } 37 | 38 | func testWrongStatusCodeErrorIncludesResponseBody() throws { 39 | let url = URL(string: "http://www.example.com/internal-error.json")! 40 | let internalErrorResponse = "{ message: \"Some troubleshooting message from the server.\" }".data(using: .utf8)! 41 | 42 | TinyHTTPStubURLProtocol.urls[url] = StubbedResponse(response: HTTPURLResponse(url: url, statusCode: 500, httpVersion: nil, headerFields: nil)!, data: internalErrorResponse) 43 | 44 | let endpoint = Endpoint<[Person]>(json: .get, url: url) 45 | let expectation = self.expectation(description: "Stubbed network call") 46 | 47 | let task = URLSession.shared.load(endpoint) { result in 48 | switch result { 49 | case .success: 50 | XCTFail("Expected an Error in Result.") 51 | case let .failure(error): 52 | XCTAssertNotNil(error as? WrongStatusCodeError) 53 | if let error = error as? WrongStatusCodeError { 54 | XCTAssertNotNil(error.responseBody) 55 | } 56 | expectation.fulfill() 57 | } 58 | } 59 | 60 | task.resume() 61 | 62 | wait(for: [expectation], timeout: 1) 63 | } 64 | 65 | static var allTests = [ 66 | ("testDataTaskRequest", testDataTaskRequest), 67 | ("testWrongStatusCodeErrorIncludesResponseBody", testWrongStatusCodeErrorIncludesResponseBody) 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /Tests/TinyNetworkingTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(TinyNetworkingTests.allTests), 7 | testCase(URLSessionIntegrationTests.allTests), 8 | ] 9 | } 10 | #endif 11 | --------------------------------------------------------------------------------