├── .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 |
--------------------------------------------------------------------------------