Skip to content

Commit 11f9a0b

Browse files
committed
Add HTTP client
1 parent 72da79a commit 11f9a0b

File tree

10 files changed

+290
-0
lines changed

10 files changed

+290
-0
lines changed

Package.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// swift-tools-version:5.3
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: "HTTPClient",
8+
products: [
9+
// Products define the executables and libraries a package produces, and make them visible to other packages.
10+
.library(
11+
name: "HTTPClient",
12+
targets: ["HTTPClient"]),
13+
],
14+
dependencies: [
15+
// Dependencies declare other packages that this package depends on.
16+
// .package(url: /* package url */, from: "1.0.0"),
17+
],
18+
targets: [
19+
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
20+
// Targets can depend on other targets in this package, and on products in packages this package depends on.
21+
.target(
22+
name: "HTTPClient",
23+
dependencies: []),
24+
.testTarget(
25+
name: "HTTPClientTests",
26+
dependencies: ["HTTPClient"]),
27+
]
28+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/// Content-Type.
2+
public enum ContentType: String {
3+
case applicationJson = "application/json"
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// HTTP header field.
2+
public enum HTTPHeaderField: String {
3+
case contentType = "Content-Type"
4+
case authorization = "Authorization"
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/// HTTP method.
2+
public enum HTTPMethod: String {
3+
case get = "GET"
4+
case post = "POST"
5+
case put = "PUT"
6+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/// Request error.
2+
public enum RequestError: Error {
3+
case invalidUrl
4+
case invalidData
5+
case invalidResponse
6+
case redirection(_ statusCode: Int)
7+
case clientError(_ statusCode: Int)
8+
case serverError(_ statusCode: Int)
9+
case invalidStatusCode(_ statusCode: Int)
10+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import Foundation
2+
3+
/// HTTP client.
4+
public final class HTTPClient {
5+
6+
// MARK: Stored Instance Properties
7+
8+
private let baseURLString: String
9+
10+
// MARK: Initializers
11+
12+
/// Initializer.
13+
/// - Parameter baseURLString: base URL string.
14+
public init(baseURLString: String) {
15+
self.baseURLString = baseURLString
16+
}
17+
18+
// MARK: Other Public Methods
19+
20+
/// Send an HTTP request.
21+
///
22+
/// No response.
23+
///
24+
/// - Parameters:
25+
/// - requestContents: Request contents.
26+
/// - completion: Completion handler.
27+
public func request<T: Request>(_ requestContents: T, completion: @escaping (Error?) -> Void) {
28+
let request: URLRequest
29+
do {
30+
request = try createRequest(requestContents)
31+
} catch let error {
32+
completion(error)
33+
return
34+
}
35+
36+
self.request(request, completion: completion)
37+
}
38+
39+
/// Send an HTTP request.
40+
///
41+
/// Response.
42+
///
43+
/// - Parameters:
44+
/// - requestContents: Request contents.
45+
/// - completion: Completion handler.
46+
public func request<T: Request>(_ requestContents: T, completion: @escaping (Result<T.ResponseBody, Error>) -> Void) {
47+
let request: URLRequest
48+
do {
49+
request = try createRequest(requestContents)
50+
} catch let error {
51+
completion(.failure(error))
52+
return
53+
}
54+
55+
self.request(requestContents, request: request, completion: completion)
56+
}
57+
58+
/// Send an HTTP request.
59+
///
60+
/// No response.
61+
///
62+
/// - Parameters:
63+
/// - requestContents: Request contents.
64+
/// - requestBody: Request body.
65+
/// - completion: Completion handler.
66+
public func request<T: Request, U: Encodable>(_ requestContents: T, requestBody: U, completion: @escaping (Error?) -> Void) {
67+
var request: URLRequest
68+
do {
69+
request = try createRequest(requestContents)
70+
request.httpBody = try JSONEncoder().encode(requestBody)
71+
} catch let error {
72+
completion(error)
73+
return
74+
}
75+
76+
self.request(request, completion: completion)
77+
}
78+
79+
/// Send an HTTP request.
80+
///
81+
/// Response.
82+
///
83+
/// - Parameters:
84+
/// - requestContents: Request contents.
85+
/// - requestBody: Request body.
86+
/// - completion: Completion handler.
87+
public func request<T: Request, U: Encodable>(_ requestContents: T, requestBody: U, completion: @escaping (Result<T.ResponseBody, Error>) -> Void) {
88+
var request: URLRequest
89+
do {
90+
request = try createRequest(requestContents)
91+
request.httpBody = try JSONEncoder().encode(requestBody)
92+
} catch let error {
93+
completion(.failure(error))
94+
return
95+
}
96+
97+
self.request(requestContents, request: request, completion: completion)
98+
}
99+
100+
// MARK: Other Private Methods
101+
102+
private func createRequest<T: Request>(_ requestContents: T) throws -> URLRequest {
103+
guard let url = URL(string: baseURLString + requestContents.path),
104+
var components = URLComponents(url: url, resolvingAgainstBaseURL: url.baseURL != nil)
105+
else {
106+
throw RequestError.invalidUrl
107+
}
108+
109+
if let queryItems = requestContents.queryItems {
110+
components.queryItems = queryItems + (components.queryItems ?? [])
111+
}
112+
113+
var request = URLRequest(url: components.url!)
114+
request.httpMethod = requestContents.httpMethod.rawValue
115+
if let httpHeaders = requestContents.httpHeaders {
116+
for (field, value) in httpHeaders {
117+
request.addValue(value, forHTTPHeaderField: field.rawValue)
118+
}
119+
}
120+
121+
return request
122+
}
123+
124+
private func request(_ request: URLRequest, completion: @escaping (Error?) -> Void) {
125+
URLSession.shared.dataTask(with: request) { data, response, error in
126+
if let error = error {
127+
completion(error)
128+
return
129+
}
130+
if let requestError = self.validateResponse(response) {
131+
completion(requestError)
132+
return
133+
}
134+
completion(nil)
135+
return
136+
}.resume()
137+
}
138+
139+
private func request<T: Request>(_ requestContents: T, request: URLRequest, completion: @escaping (Result<T.ResponseBody, Error>) -> Void) {
140+
URLSession.shared.dataTask(with: request) { data, response, error in
141+
if let error = error {
142+
completion(.failure(error))
143+
return
144+
}
145+
if let requestError = self.validateResponse(response) {
146+
completion(.failure(requestError))
147+
return
148+
}
149+
guard let data = data else {
150+
completion(.failure(RequestError.invalidData))
151+
return
152+
}
153+
do {
154+
let jsonDecoder = JSONDecoder()
155+
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
156+
let responseBody = try jsonDecoder.decode(T.ResponseBody.self, from: data)
157+
completion(.success(responseBody))
158+
return
159+
} catch let error {
160+
completion(.failure(error))
161+
return
162+
}
163+
}.resume()
164+
}
165+
166+
private func validateResponse( _ response: URLResponse?) -> RequestError? {
167+
guard let response = response as? HTTPURLResponse else {
168+
return .invalidResponse
169+
}
170+
if let requestError = self.validateStatusCode(response.statusCode) {
171+
return requestError
172+
}
173+
return nil
174+
}
175+
176+
private func validateStatusCode(_ statusCode: Int) -> RequestError? {
177+
switch statusCode {
178+
case 100..<200: // Informational
179+
return nil
180+
case 200..<300: // Success
181+
return nil
182+
case 300..<400:
183+
return .redirection(statusCode)
184+
case 400..<500:
185+
return .clientError(statusCode)
186+
case 500..<600:
187+
return .serverError(statusCode)
188+
default:
189+
return .invalidStatusCode(statusCode)
190+
}
191+
}
192+
}

Sources/HTTPClient/Request.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Foundation
2+
3+
/// Request contents.
4+
public protocol Request {
5+
associatedtype ResponseBody: Decodable
6+
var path: String { get }
7+
var httpMethod: HTTPMethod { get }
8+
var queryItems: [URLQueryItem]? { get }
9+
var httpHeaders: [HTTPHeaderField: String]? { get }
10+
}
11+
12+
extension Request {
13+
var queryItems: [URLQueryItem]? { nil }
14+
var httpHeaders: [HTTPHeaderField: String]? { nil }
15+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import XCTest
2+
@testable import HTTPClient
3+
4+
final class HTTPClientTests: XCTestCase {
5+
func testExample() {
6+
// This is an example of a functional test case.
7+
// Use XCTAssert and related functions to verify your tests produce the correct
8+
// results.
9+
}
10+
11+
static var allTests = [
12+
("testExample", testExample),
13+
]
14+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import XCTest
2+
3+
#if !canImport(ObjectiveC)
4+
public func allTests() -> [XCTestCaseEntry] {
5+
return [
6+
testCase(HTTPClientTests.allTests),
7+
]
8+
}
9+
#endif

Tests/LinuxMain.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import XCTest
2+
3+
import HTTPClientTests
4+
5+
var tests = [XCTestCaseEntry]()
6+
tests += HTTPClientTests.allTests()
7+
XCTMain(tests)

0 commit comments

Comments
 (0)