├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── CHANGELOG.md ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── HTTP │ ├── BodyRequest.swift │ ├── Client.swift │ ├── Empty.swift │ ├── Global.swift │ ├── HTTPError.swift │ ├── Method.swift │ ├── Request.swift │ ├── RequestLoader.swift │ └── Response.swift └── Tests ├── HTTPTests ├── BodyRequestTests.swift ├── ClientTests.swift ├── GlobalTests.swift ├── RequestTests.swift ├── Support │ ├── Extensions │ │ └── Foundation │ │ │ ├── JSONDecoder.swift │ │ │ └── JSONEncoder.swift │ ├── Fakes │ │ └── FakeRequestLoader.swift │ ├── Fixtures.swift │ └── Helpers │ │ ├── READMEHelper.swift │ │ ├── ResultHelpers.swift │ │ └── URLHelper.swift └── XCTestManifests.swift └── LinuxMain.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [joemasilotti] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | build: 13 | runs-on: macos-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Run tests 18 | run: swift test 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2024 4 | 5 | * February 13 - Option to set a user agent for all request - [#5](https://github.com/joemasilotti/HTTP-Client/pull/5) 6 | * February 13 - Expose status code when possible - [#4](https://github.com/joemasilotti/HTTP-Client/pull/4) 7 | 8 | ## 2022 9 | 10 | * November 18 - Fix exception when running on iOS 14.4 11 | * July 20 - Set default key coding strategy to snake case - [#3](https://github.com/joemasilotti/HTTP-Client/pull/3) 12 | * July 20 - Convert project to async-await - [#2](https://github.com/joemasilotti/HTTP-Client/pull/2) 13 | 14 | ## 2021 15 | 16 | * April 27 - Add PATCH HTTP method 17 | * April 20 - Option for key encoding strategy on `BodyRequest` 18 | * April 20 - Add DELETE HTTP method 19 | * April 19 - Add status code to `.invalidResponse` error 20 | * April 5 - Global config for `RequestLoader` (for tests) 21 | * April 3 - Add header configuration to requests - [#1](Ghttps://github.com/joemasilotti/HTTP-Client/pull/1) [@andrejacobs](https://github.com/andrejacobs) 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Joe Masilotti 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.3 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "HTTP Client", 7 | platforms: [ 8 | .macOS(.v10_15), 9 | .iOS(.v14), 10 | ], 11 | products: [ 12 | .library( 13 | name: "HTTP", 14 | targets: ["HTTP"] 15 | ), 16 | ], 17 | dependencies: [], 18 | targets: [ 19 | .target( 20 | name: "HTTP", 21 | dependencies: [] 22 | ), 23 | .testTarget( 24 | name: "HTTPTests", 25 | dependencies: ["HTTP"] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HTTP Client 2 | 3 | A barebones async-await Swift HTTP client with automatic JSON response parsing. 4 | 5 | ## Installation 6 | 7 | Add HTTP Client as a dependency through Xcode or directly to Package.swift: 8 | 9 | ``` 10 | .package(url: "https://github.com/joemasilotti/HTTP-Client", .upToNextMinor(from: "0.1")) 11 | ``` 12 | 13 | ## Usage 14 | 15 | ### GET request with empty responses 16 | 17 | ```swift 18 | import HTTP 19 | 20 | let client = Client() 21 | let request = Request(url: url) 22 | switch await client.request(request) { 23 | case .success: print("Success!") 24 | case .failure(let error): print(error.localizedDescription) 25 | } 26 | ``` 27 | 28 | ### POST request with success and response objects 29 | 30 | Failure response objects are parsed when the response code is outside of the 200 range. 31 | 32 | ```swift 33 | import HTTP 34 | 35 | 36 | struct Registration: Codable { 37 | let email: String 38 | let password: String 39 | } 40 | 41 | struct User: Codable { 42 | let id: Int 43 | let isAdmin: Bool 44 | } 45 | 46 | struct RegistrationError: LocalizedError, Codable, Equatable { 47 | let status: Int 48 | let message: String 49 | 50 | var errorDescription: String? { message } 51 | } 52 | 53 | let client = Client() 54 | let registration = Registration(email: "joe@masilotti.com", password: "password") 55 | let request = BodyRequest(url: url, method: .post, body: registration) 56 | switch await client.request(request) { 57 | case .success(let response): 58 | print("HTTP headers", response.headers) 59 | print("User", response.value) 60 | case .failure(let error): 61 | print("Error", error.localizedDescription) 62 | } 63 | ``` 64 | 65 | ### Status code 66 | 67 | When possible, a status code is also exposed. 68 | 69 | ```swift 70 | import HTTP 71 | 72 | let client = Client() 73 | let request = Request(url: url) 74 | switch await client.request(request) { 75 | case .success(let statusCode): 76 | print("Status code", statusCode) 77 | case .failure(let error): 78 | print("Status code", error.statusCode ?? "(none)") 79 | } 80 | ``` 81 | 82 | ### HTTP headers 83 | 84 | HTTP headers can also be set on `Request`. 85 | 86 | ```swift 87 | import HTTP 88 | 89 | let client = Client() 90 | let headers = ["Cookie": "tasty_cookie=strawberry"] 91 | let request = Request(url: url, headers: headers) 92 | _ = await client.request(request) 93 | ``` 94 | 95 | ### Custom `URLRequest` 96 | 97 | `URLRequest` can be used directly if you require more fine grained control. 98 | 99 | ```swift 100 | import HTTP 101 | 102 | let client = Client() 103 | let request = URLRequest( 104 | url: url, 105 | cachePolicy: .reloadIgnoringLocalCacheData, 106 | timeoutInterval: 42.0 107 | ) 108 | _ = await client.request(request) 109 | ``` 110 | 111 | ### User agent 112 | 113 | A user agent can also be set for all requests, assigning the `"User-Agent"` header. 114 | 115 | ```swift 116 | import HTTP 117 | 118 | HTTP.Global.userAgent = "Custom User Agent" 119 | ``` 120 | 121 | ### Key encoding strategies 122 | 123 | By default, all encoding and decoding of keys to JSON is done by converting to snake case. 124 | 125 | This can be changed via the global configuration. 126 | 127 | ```swift 128 | import HTTP 129 | 130 | HTTP.Global.keyDecodingStrategy = .useDefaultKeys 131 | HTTP.Global.keyEncodingStrategy = .useDefaultKeys 132 | ``` 133 | -------------------------------------------------------------------------------- /Sources/HTTP/BodyRequest.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class BodyRequest: Request { 4 | public init(url: URL, method: Method = .get, body: T, headers: Headers = [:]) { 5 | self.body = body 6 | super.init(url: url, method: method, headers: headers) 7 | } 8 | 9 | // MARK: Internal 10 | 11 | override func addToRequest(_ request: inout URLRequest) { 12 | let encoder = JSONEncoder() 13 | encoder.keyEncodingStrategy = Global.keyEncodingStrategy 14 | request.httpBody = try? encoder.encode(body) 15 | request.setValue("application/json", forHTTPHeaderField: "Accept") 16 | request.setValue("application/json", forHTTPHeaderField: "Content-Type") 17 | } 18 | 19 | // MARK: Private 20 | 21 | private let body: T 22 | } 23 | -------------------------------------------------------------------------------- /Sources/HTTP/Client.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias ClientResult = Result, HTTPError> 4 | where T: Decodable, E: LocalizedError & Decodable & Equatable 5 | 6 | public struct Client where T: Decodable, E: LocalizedError & Decodable & Equatable { 7 | public init(requestLoader: RequestLoader = Global.requestLoader) { 8 | self.requestLoader = requestLoader 9 | } 10 | 11 | @MainActor 12 | public func request(_ request: Request) async -> ClientResult { 13 | await self.request(request.asURLRequest) 14 | } 15 | 16 | @MainActor 17 | public func request(_ request: URLRequest) async -> ClientResult { 18 | do { 19 | let (data, response) = try await requestLoader.load(request) 20 | return handleResponse(response, with: data) 21 | } catch { 22 | return .failure(.failedRequest(error as? URLError)) 23 | } 24 | } 25 | 26 | // MARK: Private 27 | 28 | private let requestLoader: RequestLoader 29 | 30 | private func handleResponse(_ response: URLResponse, with data: Data) -> ClientResult { 31 | guard let response = response as? HTTPURLResponse 32 | else { return .failure(.failedRequest(nil)) } 33 | 34 | if (200 ..< 300).contains(response.statusCode) { 35 | return handleSuccess(data, headers: response.allHeaderFields, statusCode: response.statusCode) 36 | } else { 37 | return handleFailure(data, statusCode: response.statusCode) 38 | } 39 | } 40 | 41 | private func handleSuccess(_ data: Data, headers: [AnyHashable: Any], statusCode: Int) -> ClientResult { 42 | if let value: T = parse(data) { 43 | return .success(Response(headers: headers, value: value, statusCode: statusCode)) 44 | } else { 45 | return .failure(.responseTypeMismatch(statusCode)) 46 | } 47 | } 48 | 49 | private func handleFailure(_ data: Data, statusCode: Int) -> ClientResult { 50 | if let error: E = parse(data) { 51 | return .failure(.invalidRequest(error, statusCode)) 52 | } else { 53 | return .failure(.invalidResponse(statusCode)) 54 | } 55 | } 56 | 57 | private func parse(_ data: Data?) -> D? { 58 | guard let data = data else { return nil } 59 | let decoder = JSONDecoder() 60 | decoder.keyDecodingStrategy = Global.keyDecodingStrategy 61 | return try? decoder.decode(D.self, from: data) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/HTTP/Empty.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Empty: Decodable, LocalizedError, Equatable {} 4 | -------------------------------------------------------------------------------- /Sources/HTTP/Global.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum Global { 4 | public static var keyDecodingStrategy: JSONDecoder.KeyDecodingStrategy = .convertFromSnakeCase 5 | public static var keyEncodingStrategy: JSONEncoder.KeyEncodingStrategy = .convertToSnakeCase 6 | public static var userAgent: String? 7 | public static var requestLoader: RequestLoader = URLSession.shared 8 | 9 | static func resetToDefaults() { 10 | keyDecodingStrategy = .convertFromSnakeCase 11 | keyEncodingStrategy = .convertToSnakeCase 12 | userAgent = nil 13 | requestLoader = URLSession.shared 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/HTTP/HTTPError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum HTTPError: LocalizedError { 4 | case failedRequest(URLError?) 5 | case invalidRequest(T, Int) 6 | case invalidResponse(Int) 7 | case responseTypeMismatch(Int) 8 | 9 | public var errorDescription: String? { 10 | switch self { 11 | case .failedRequest: 12 | return "The request failed." 13 | case let .invalidRequest(error, _): 14 | return error.localizedDescription 15 | case let .invalidResponse(statusCode): 16 | return "The response was invalid (\(statusCode))." 17 | case .responseTypeMismatch: 18 | return "The response did not match the expected type." 19 | } 20 | } 21 | 22 | public var failureReason: String? { 23 | switch self { 24 | case let .failedRequest(error): 25 | return error?.localizedDescription 26 | case let .invalidRequest(error, _): 27 | return error.localizedDescription 28 | case let .invalidResponse(statusCode): 29 | return "The server returned a \(statusCode) status code." 30 | case .responseTypeMismatch: 31 | return "The response did not match the expected error type." 32 | } 33 | } 34 | 35 | public var statusCode: Int? { 36 | if case let .invalidRequest(_, statusCode) = self { 37 | return statusCode 38 | } else if case let .invalidResponse(statusCode) = self { 39 | return statusCode 40 | } else if case let .responseTypeMismatch(statusCode) = self { 41 | return statusCode 42 | } 43 | return nil 44 | } 45 | } 46 | 47 | extension HTTPError: Equatable where T: Equatable {} 48 | -------------------------------------------------------------------------------- /Sources/HTTP/Method.swift: -------------------------------------------------------------------------------- 1 | public enum Method: String { 2 | case delete = "DELETE" 3 | case get = "GET" 4 | case patch = "PATCH" 5 | case post = "POST" 6 | } 7 | -------------------------------------------------------------------------------- /Sources/HTTP/Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class Request { 4 | public typealias Headers = [String: String] 5 | 6 | public init(url: URL, method: Method = .get, headers: Headers = [:]) { 7 | self.url = url 8 | self.method = method 9 | 10 | if let userAgent = Global.userAgent { 11 | self.headers = headers.merging(["User-Agent": userAgent]) { _, new in new } 12 | } else { 13 | self.headers = headers 14 | } 15 | } 16 | 17 | // MARK: Internal 18 | 19 | var asURLRequest: URLRequest { 20 | var request = URLRequest(url: url) 21 | request.httpMethod = method.rawValue 22 | request.allHTTPHeaderFields = headers 23 | addToRequest(&request) 24 | return request 25 | } 26 | 27 | func addToRequest(_ request: inout URLRequest) {} 28 | 29 | // MARK: Private 30 | 31 | private let headers: Headers 32 | private let method: Method 33 | private let url: URL 34 | } 35 | -------------------------------------------------------------------------------- /Sources/HTTP/RequestLoader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RequestLoader { 4 | func load(_ request: URLRequest) async throws -> (Data, URLResponse) 5 | } 6 | 7 | extension URLSession: RequestLoader { 8 | public func load(_ request: URLRequest) async throws -> (Data, URLResponse) { 9 | if #available(iOS 15.0, macOS 12.0, *) { 10 | return try await data(for: request) 11 | } else { 12 | return try await _data(for: request) 13 | } 14 | } 15 | 16 | private func _data(for request: URLRequest) async throws -> (Data, URLResponse) { 17 | try await withCheckedThrowingContinuation { continuation in 18 | let task = self.dataTask(with: request) { data, response, error in 19 | guard let data = data, let response = response else { 20 | let error = error ?? URLError(.badServerResponse) 21 | return continuation.resume(throwing: error) 22 | } 23 | 24 | continuation.resume(returning: (data, response)) 25 | } 26 | 27 | task.resume() 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/HTTP/Response.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct Response { 4 | public let headers: [AnyHashable: Any] 5 | public let value: T 6 | public let statusCode: Int 7 | } 8 | -------------------------------------------------------------------------------- /Tests/HTTPTests/BodyRequestTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HTTP 2 | import XCTest 3 | 4 | class BodyRequestTests: XCTestCase { 5 | override func tearDown() { 6 | Global.resetToDefaults() 7 | } 8 | 9 | // MARK: asURLRequest 10 | 11 | func test_asURLRequest_encodesItsBody() throws { 12 | let request = BodyRequest(url: URL.test, body: TestObject()) 13 | let urlRequest = request.asURLRequest 14 | 15 | let data = try XCTUnwrap(urlRequest.httpBody) 16 | let object = try? JSONDecoder.convertingKeysFromSnakeCase.decode(TestObject.self, from: data) 17 | XCTAssertEqual(object, TestObject()) 18 | } 19 | 20 | func test_init_identifiesAsAJSONRequestToRails() { 21 | let request = BodyRequest(url: URL.test, body: TestObject()) 22 | let urlRequest = request.asURLRequest 23 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Accept"), "application/json") 24 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json") 25 | } 26 | 27 | func test_init_setsTheAllHTTPHeaderFields() { 28 | let request = BodyRequest( 29 | url: URL.test, 30 | body: TestObject(), 31 | headers: ["Cookie": "yummy_cookie=choco;"] 32 | ) 33 | 34 | let urlRequest = request.asURLRequest 35 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Cookie"), "yummy_cookie=choco;") 36 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Content-Type"), "application/json") 37 | } 38 | 39 | func test_init_usesSnakeCaseKeyEncodingStrategy() throws { 40 | Global.keyEncodingStrategy = .convertToSnakeCase 41 | let request = BodyRequest(url: URL.test, body: TestObject(secondProperty: "value")) 42 | 43 | let json = try decodeRequest(request) 44 | XCTAssertEqual(json["second_property"], "value") 45 | } 46 | 47 | func test_init_usesDefaultKeyEncodingStrategy() throws { 48 | Global.keyEncodingStrategy = .useDefaultKeys 49 | let request = BodyRequest(url: URL.test, body: TestObject(secondProperty: "value")) 50 | 51 | let json = try decodeRequest(request) 52 | XCTAssertEqual(json["secondProperty"], "value") 53 | } 54 | 55 | func test_init_setsGlobalUserAgent() throws { 56 | Global.userAgent = "Custom User Agent" 57 | let request = BodyRequest(url: URL.test, body: TestObject()) 58 | 59 | let urlRequest = request.asURLRequest 60 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "User-Agent"), "Custom User Agent") 61 | } 62 | 63 | private func decodeRequest(_ request: Request) throws -> [String: String] { 64 | let urlRequest = request.asURLRequest 65 | let data = try XCTUnwrap(urlRequest.httpBody) 66 | return try XCTUnwrap(JSONSerialization.jsonObject(with: data, options: []) as? [String: String]) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/HTTPTests/ClientTests.swift: -------------------------------------------------------------------------------- 1 | import HTTP 2 | import XCTest 3 | 4 | final class ClientTests: XCTestCase { 5 | // MARK: request(_:completion:) 6 | 7 | func test_request_loadsTheRequest() async { 8 | let requestLoader = FakeRequestLoader() 9 | let client = Client(requestLoader: requestLoader) 10 | 11 | _ = await client.request(Request(url: URL.test)) 12 | 13 | XCTAssertEqual(requestLoader.lastLoadedRequest, URLRequest.test) 14 | } 15 | 16 | func test_request_withURLRequest_loadsTheRequest() async { 17 | let requestLoader = FakeRequestLoader() 18 | let client = Client(requestLoader: requestLoader) 19 | 20 | let expectedURLRequest = URLRequest.testWithExtraProperties 21 | _ = await client.request(expectedURLRequest) 22 | 23 | XCTAssertEqual(requestLoader.lastLoadedRequest, expectedURLRequest) 24 | } 25 | 26 | func test_request_failsWithANetworkError() async { 27 | let requestLoader = FakeRequestLoader() 28 | let client = Client(requestLoader: requestLoader) 29 | 30 | let networkError = URLError(.badURL) 31 | requestLoader.nextError = networkError 32 | 33 | let result = await client.request(Request(url: URL.test)) 34 | assertResultError(result, .failedRequest(URLError(.badURL))) 35 | } 36 | 37 | func test_request_200range_succeedsWithParsedSuccessObject() async throws { 38 | let requestLoader = FakeRequestLoader() 39 | let client = Client(requestLoader: requestLoader) 40 | 41 | let exampleObject = TestObject() 42 | let data = try XCTUnwrap(JSONEncoder.convertingKeysFromSnakeCase.encode(exampleObject)) 43 | requestLoader.nextData = data 44 | 45 | let response = HTTPURLResponse(url: URL.test, statusCode: 200, httpVersion: nil, headerFields: ["HEADER": "value"]) 46 | requestLoader.nextResponse = response 47 | 48 | let result = await client.request(Request(url: URL.test)) 49 | XCTAssertEqual(try? result.get().value, exampleObject) 50 | XCTAssertEqual(try? result.get().headers as? [String: String], ["HEADER": "value"]) 51 | XCTAssertEqual(try? result.get().statusCode, 200) 52 | } 53 | 54 | func test_request_200range_failsWhenParsingFails() async { 55 | let requestLoader = FakeRequestLoader() 56 | let client = Client(requestLoader: requestLoader) 57 | 58 | let response = HTTPURLResponse(url: URL.test, statusCode: 200, httpVersion: nil, headerFields: nil) 59 | requestLoader.nextResponse = response 60 | 61 | let result = await client.request(Request(url: URL.test)) 62 | assertResultError(result, .responseTypeMismatch(200)) 63 | } 64 | 65 | func test_request_non200range_failsWithParsedErrorObject() async throws { 66 | let requestLoader = FakeRequestLoader() 67 | let client = Client(requestLoader: requestLoader) 68 | 69 | let error = TestError(message: "Example message.") 70 | let data = try XCTUnwrap(JSONEncoder().encode(error)) 71 | requestLoader.nextData = data 72 | 73 | let response = HTTPURLResponse(url: URL.test, statusCode: 403, httpVersion: nil, headerFields: nil) 74 | requestLoader.nextResponse = response 75 | 76 | let result = await client.request(Request(url: URL.test)) 77 | assertResultError(result, .invalidRequest(error, 403)) 78 | } 79 | 80 | func test_request_non200range_failsWhenParsingFails() async { 81 | let requestLoader = FakeRequestLoader() 82 | let client = Client(requestLoader: requestLoader) 83 | 84 | let response = HTTPURLResponse(url: URL.test, statusCode: 403, httpVersion: nil, headerFields: nil) 85 | requestLoader.nextResponse = response 86 | 87 | let result = await client.request(Request(url: URL.test)) 88 | assertResultError(result, .invalidResponse(403)) 89 | } 90 | 91 | func test_request_failsWhenNotAnHTTPResponse() async { 92 | let requestLoader = FakeRequestLoader() 93 | let client = Client(requestLoader: requestLoader) 94 | 95 | requestLoader.nextResponse = URLResponse() 96 | 97 | let result = await client.request(Request(url: URL.test)) 98 | assertResultError(result, .failedRequest(nil)) 99 | } 100 | 101 | func test_request_nonSnakeCaseKeyCodingStrategy() async throws { 102 | let requestLoader = FakeRequestLoader() 103 | HTTP.Global.keyEncodingStrategy = .useDefaultKeys 104 | HTTP.Global.keyDecodingStrategy = .useDefaultKeys 105 | let client = Client(requestLoader: requestLoader) 106 | 107 | let exampleObject = TestObject() 108 | let data = try XCTUnwrap(JSONEncoder().encode(exampleObject)) 109 | requestLoader.nextData = data 110 | 111 | let result = await client.request(Request(url: URL.test)) 112 | XCTAssertEqual(try? result.get().value, exampleObject) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Tests/HTTPTests/GlobalTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HTTP 2 | import XCTest 3 | 4 | class GlobalTests: XCTestCase { 5 | override class func setUp() { 6 | Global.resetToDefaults() 7 | } 8 | 9 | override class func tearDown() { 10 | Global.resetToDefaults() 11 | } 12 | 13 | func test_requestLoader_defaultsToTheSharedURLSession() { 14 | XCTAssert(Global.requestLoader as? URLSession === URLSession.shared) 15 | } 16 | 17 | func test_requestLoader_isUsedInClient() async { 18 | let requestLoader = FakeRequestLoader() 19 | Global.requestLoader = requestLoader 20 | let client = Client() 21 | 22 | _ = await client.request(Request(url: URL.test)) 23 | 24 | XCTAssertEqual(requestLoader.lastLoadedRequest, URLRequest.test) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/HTTPTests/RequestTests.swift: -------------------------------------------------------------------------------- 1 | @testable import HTTP 2 | import XCTest 3 | 4 | class RequestTests: XCTestCase { 5 | // MARK: asURLRequest 6 | 7 | func test_asURLRequest_setsTheURL() { 8 | let getRequest = Request(url: URL.test) 9 | XCTAssertEqual(getRequest.asURLRequest.url, URL.test) 10 | } 11 | 12 | func test_asURLRequest_setsTheHTTPMethod() { 13 | let deleteRequest = Request(url: URL.test, method: .delete) 14 | XCTAssertEqual(deleteRequest.asURLRequest.httpMethod, "DELETE") 15 | 16 | let getRequest = Request(url: URL.test) 17 | XCTAssertEqual(getRequest.asURLRequest.httpMethod, "GET") 18 | 19 | let patchRequest = Request(url: URL.test, method: .patch) 20 | XCTAssertEqual(patchRequest.asURLRequest.httpMethod, "PATCH") 21 | 22 | let postRequest = Request(url: URL.test, method: .post) 23 | XCTAssertEqual(postRequest.asURLRequest.httpMethod, "POST") 24 | } 25 | 26 | func test_asURLRequest_setsAllHTTPHeaderFields() { 27 | let headers = ["Cookie": "yummy_cookie=choco; tasty_cookie=strawberry;"] 28 | let requestWithHeaders = Request(url: URL.test, headers: headers) 29 | XCTAssertEqual(requestWithHeaders.asURLRequest.allHTTPHeaderFields, headers) 30 | } 31 | 32 | func test_init_setsGlobalUserAgent() throws { 33 | Global.userAgent = "Custom User Agent" 34 | let request = Request(url: URL.test) 35 | 36 | let urlRequest = request.asURLRequest 37 | XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "User-Agent"), "Custom User Agent") 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Extensions/Foundation/JSONDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONDecoder { 4 | static var convertingKeysFromSnakeCase: JSONDecoder { 5 | let decoder = JSONDecoder() 6 | decoder.keyDecodingStrategy = .convertFromSnakeCase 7 | return decoder 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Extensions/Foundation/JSONEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension JSONEncoder { 4 | static var convertingKeysFromSnakeCase: JSONEncoder { 5 | let decoder = JSONEncoder() 6 | decoder.keyEncodingStrategy = .convertToSnakeCase 7 | return decoder 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Fakes/FakeRequestLoader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTP 3 | 4 | class FakeRequestLoader: RequestLoader { 5 | var nextData: Data? 6 | var nextResponse: URLResponse? 7 | var nextError: URLError? 8 | 9 | private(set) var lastLoadedRequest: URLRequest? 10 | 11 | func load(_ request: URLRequest, completion: @escaping (Data?, URLResponse?, URLError?) -> Void) { 12 | lastLoadedRequest = request 13 | completion(nextData, nextResponse, nextError) 14 | } 15 | 16 | func load(_ request: URLRequest) async throws -> (Data, URLResponse) { 17 | lastLoadedRequest = request 18 | if let error = nextError { 19 | throw error 20 | } 21 | return (nextData ?? Data(), nextResponse ?? HTTPURLResponse()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Fixtures.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct TestObject: Codable, Equatable { 4 | let property: String 5 | let secondProperty: String 6 | 7 | init(property: String = "First property", secondProperty: String = "Second property") { 8 | self.property = property 9 | self.secondProperty = secondProperty 10 | } 11 | } 12 | 13 | struct TestError: LocalizedError, Codable, Equatable { 14 | let message: String 15 | } 16 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Helpers/READMEHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import HTTP 3 | 4 | let url = URL.test 5 | 6 | struct GETRequestExample { 7 | func example() async { 8 | let client = Client() 9 | let request = Request(url: url) 10 | switch await client.request(request) { 11 | case .success: print("Success!") 12 | case .failure(let error): print(error.localizedDescription) 13 | } 14 | } 15 | } 16 | 17 | struct POSTRequestExample { 18 | func example() async { 19 | struct Registration: Codable { 20 | let email: String 21 | let password: String 22 | } 23 | 24 | struct User: Codable { 25 | let id: Int 26 | let isAdmin: Bool 27 | } 28 | 29 | struct RegistrationError: LocalizedError, Codable, Equatable { 30 | let status: Int 31 | let message: String 32 | 33 | var errorDescription: String? { message } 34 | } 35 | 36 | let client = Client() 37 | let registration = Registration(email: "joe@masilotti.com", password: "password") 38 | let request = BodyRequest(url: url, method: .post, body: registration) 39 | switch await client.request(request) { 40 | case .success(let response): 41 | print("HTTP headers", response.headers) 42 | print("User", response.value) 43 | case .failure(let error): 44 | print("Error", error.localizedDescription) 45 | } 46 | } 47 | } 48 | 49 | struct StatusCodeExmaple { 50 | func example() async { 51 | let client = Client() 52 | let request = Request(url: url) 53 | switch await client.request(request) { 54 | case .success(let statusCode): 55 | print("Status code", statusCode) 56 | case .failure(let error): 57 | print("Status code", error.statusCode ?? "(none)") 58 | } 59 | } 60 | } 61 | 62 | struct RequestWithHeadersExample { 63 | func example() async { 64 | let client = Client() 65 | let headers = ["Cookie": "tasty_cookie=strawberry"] 66 | let request = Request(url: url, headers: headers) 67 | _ = await client.request(request) 68 | } 69 | } 70 | 71 | struct URLRequestExample { 72 | func example() async { 73 | let client = Client() 74 | let request = URLRequest( 75 | url: url, 76 | cachePolicy: .reloadIgnoringLocalCacheData, 77 | timeoutInterval: 42.0 78 | ) 79 | _ = await client.request(request) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Helpers/ResultHelpers.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | func assertResultError( 4 | _ result: Result, 5 | _ expectedError: E, 6 | file: StaticString = #filePath, 7 | line: UInt = #line 8 | ) where E: Equatable { 9 | switch result { 10 | case .failure(let actualError): 11 | XCTAssertEqual(actualError, expectedError, file: file, line: line) 12 | case .success: 13 | XCTFail("Result did not fail", file: file, line: line) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/HTTPTests/Support/Helpers/URLHelper.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | static var test = Self(string: "https://example.com")! 5 | } 6 | 7 | extension URLRequest { 8 | static var test = Self(url: URL.test) 9 | static var testWithExtraProperties = Self( 10 | url: URL.test, 11 | cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, 12 | timeoutInterval: 42.0 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /Tests/HTTPTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(HTTPTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import HTTPTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += HTTPTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------