BIT-59: Add initial networking layer (#9)

This commit is contained in:
Matt Czech 2023-08-29 10:14:02 -05:00 committed by GitHub
parent 090f1a07fd
commit adf0a24d73
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1195 additions and 3 deletions

View File

@ -12,6 +12,8 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>

View File

@ -0,0 +1,32 @@
{
"configurations" : [
{
"id" : "904F040F-90D0-48AE-8E03-A700F055631B",
"name" : "Test Scheme Action",
"options" : {
}
}
],
"defaultOptions" : {
"codeCoverage" : {
"targets" : [
{
"containerPath" : "container:",
"identifier" : "Networking",
"name" : "Networking"
}
]
}
},
"testTargets" : [
{
"target" : {
"containerPath" : "container:",
"identifier" : "NetworkingTests",
"name" : "NetworkingTests"
}
}
],
"version" : 1
}

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Networking"
BuildableName = "Networking"
BlueprintName = "Networking"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NetworkingTests"
BuildableName = "NetworkingTests"
BlueprintName = "NetworkingTests"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<TestPlans>
<TestPlanReference
reference = "container:.swiftpm/Networking.xctestplan"
default = "YES">
</TestPlanReference>
</TestPlans>
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "NetworkingTests"
BuildableName = "NetworkingTests"
BlueprintName = "NetworkingTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "Networking"
BuildableName = "Networking"
BlueprintName = "Networking"
ReferencedContainer = "container:">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

23
Networking/Package.swift Normal file
View File

@ -0,0 +1,23 @@
// swift-tools-version: 5.8
import PackageDescription
let package = Package(
name: "Networking",
platforms: [
.iOS(.v15),
],
products: [
.library(
name: "Networking",
targets: ["Networking"]
),
],
targets: [
.target(name: "Networking"),
.testTarget(
name: "NetworkingTests",
dependencies: ["Networking"]
),
]
)

View File

@ -0,0 +1,10 @@
import OSLog
extension Logger {
/// Logger instance for networking logs.
static let networking = Logger(subsystem: subsystem, category: "Networking")
/// The OSLog subsystem passed along with logs to the logging system to identify logs from this
/// library.
private static var subsystem = Bundle(for: HTTPService.self).bundleIdentifier!
}

View File

@ -0,0 +1,19 @@
import Foundation
/// Conforms `URLSession` to the `HTTPClient` protocol.
///
extension URLSession: HTTPClient {
public func send(_ request: HTTPRequest) async throws -> HTTPResponse {
var urlRequest = URLRequest(url: request.url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.httpBody = request.body
for (field, value) in request.headers {
urlRequest.addValue(value, forHTTPHeaderField: field)
}
let (data, urlResponse) = try await data(for: urlRequest)
return try HTTPResponse(data: data, response: urlResponse, request: request)
}
}

View File

@ -0,0 +1,10 @@
/// A protocol for a networking client that performs HTTP requests.
///
public protocol HTTPClient {
/// Sends a `HTTPRequest` over the network, returning a `HTTPResponse`.
///
/// - Parameter request: The `HTTPRequest` to send.
/// - Returns: A `HTTPResponse` containing the data that was returned from the network request.
///
func send(_ request: HTTPRequest) async throws -> HTTPResponse
}

View File

@ -0,0 +1,48 @@
import OSLog
/// An object that handles logging HTTP requests and responses.
///
class HTTPLogger {
/// Logs the details of a `HTTPRequest`.
///
/// - Parameter httpRequest: The `HTTPRequest` to log the details of.
///
func logRequest(_ httpRequest: HTTPRequest) {
let formattedBody = formattedBody(httpRequest.body)
Logger.networking.info("""
Request \(httpRequest.requestID): \(httpRequest.method.rawValue) \(httpRequest.url)
Body: \(formattedBody)
"""
)
}
/// Logs the details of a `HTTPResponse`.
///
/// - Parameter httpResponse: The `HTTPResponse` to log the details of.
///
func logResponse(_ httpResponse: HTTPResponse) {
let formattedBody = formattedBody(httpResponse.body)
Logger.networking.info("""
Response \(httpResponse.requestID): \(httpResponse.url) \(httpResponse.statusCode)
Body: \(formattedBody)
"""
)
}
// MARK: Private
/// Formats the data in the body of a request or response for logging.
///
/// - Parameter data: The data from the body of a request or response to format.
/// - Returns: A string containing the formatted body data.
///
private func formattedBody(_ data: Data?) -> String {
guard let data, !data.isEmpty else { return "(empty)" }
if let dataString = String(data: data, encoding: .utf8) {
return dataString
}
return data.debugDescription
}
}

View File

@ -0,0 +1,23 @@
/// A type representing the HTTP method.
///
public struct HTTPMethod: Equatable {
/// The string value of the method.
let rawValue: String
}
public extension HTTPMethod {
/// The `GET` method.
static let get = HTTPMethod(rawValue: "GET")
/// The `POST` method.
static let post = HTTPMethod(rawValue: "POST")
/// The `PUT` method.
static let put = HTTPMethod(rawValue: "PUT")
/// The `DELETE` method.
static let delete = HTTPMethod(rawValue: "DELETE")
/// The `PATCH` method.
static let patch = HTTPMethod(rawValue: "PATCH")
}

View File

@ -0,0 +1,83 @@
import Foundation
/// A data model containing the details of an HTTP request to be performed.
///
public struct HTTPRequest: Equatable {
// MARK: Properties
/// Data to be sent in the body of the request.
public let body: Data?
/// Headers to be included in the request.
public let headers: [String: String]
/// The HTTP method of the request.
public let method: HTTPMethod
/// A unique identifier for the request.
public let requestID: UUID
/// The URL for the request.
public let url: URL
// MARK: Initialization
/// Initialize a `HTTPRequest`.
///
/// - Parameters:
/// - url: The URL for the request.
/// - method: The HTTP method of the request.
/// - headers: Headers to be included in the request.
/// - body: Data to be sent in the body of the request.
/// - requestID: A unique identifier for the request.
///
public init(
url: URL,
method: HTTPMethod = .get,
headers: [String: String] = [:],
body: Data? = nil,
requestID: UUID = UUID()
) {
self.body = body
self.headers = headers
self.method = method
self.requestID = requestID
self.url = url
}
}
public extension HTTPRequest {
/// Initialize a `HTTPRequest` from a `Request` instance.
///
/// - Parameters:
/// - request: The `Request` instance used to initialize the `HTTPRequest`.
/// - baseURL: The base URL that will be prepended to the `Request`'s path to construct the
/// request URL.
///
init<R: Request>(request: R, baseURL: URL) throws {
var urlComponents = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
urlComponents.queryItems = !request.query.isEmpty ? request.query : nil
if urlComponents.path.hasSuffix("/") {
urlComponents.path.removeLast()
}
guard let url = urlComponents.url?.appendingPathComponent(request.path) else {
fatalError("🛑 Request `resolve` failed: reason unknown.")
}
var headers = request.headers
if let additionalHeaders = request.body?.additionalHeaders {
for header in additionalHeaders {
headers[header.key] = header.value
}
}
try self.init(
url: url,
method: request.method,
headers: headers,
body: request.body?.encode()
)
}
}

View File

@ -0,0 +1,64 @@
import Foundation
/// A data model containing the details of an HTTP response that's been received.
///
public struct HTTPResponse: Equatable {
// MARK: Properties
/// Data received in the body of the response.
public let body: Data
/// Headers received from response.
public let headers: [String: String]
/// The response's status code.
public let statusCode: Int
/// A unique identifier for the request associated with this response.
public let requestID: UUID
/// The URL from which the response was created.
public let url: URL
// MARK: Initialization
/// Initialize a `HTTPResponse`.
///
/// - Parameters:
/// - url: The URL from which the response was created.
/// - statusCode: The response's status code.
/// - headers: Headers received from response.
/// - body: Data received in the body of the response.
/// - requestID: A unique identifier for the request associated with this response.
///
public init(url: URL, statusCode: Int, headers: [String: String], body: Data, requestID: UUID) {
self.body = body
self.headers = headers
self.statusCode = statusCode
self.requestID = requestID
self.url = url
}
/// Initialize a `HTTPResponse` with data and a `URLResponse`.
///
/// - Parameters:
/// - data: Data received in the body of the response.
/// - response: A `URLResponse` object containing the details of the response.
/// - request: The `HTTPRequest` associated with this response.
///
init(data: Data, response: URLResponse, request: HTTPRequest) throws {
guard let response = response as? HTTPURLResponse else {
throw HTTPResponseError.invalidResponse(response)
}
guard let responseURL = response.url else {
throw HTTPResponseError.noURL
}
url = responseURL
statusCode = response.statusCode
body = data
headers = response.allHeaderFields as? [String: String] ?? [:]
requestID = request.requestID
}
}

View File

@ -0,0 +1,10 @@
import Foundation
/// Errors thrown by `HTTPResponse`.
///
enum HTTPResponseError: Error, Equatable {
/// The `URLResponse` was unable to be converted to a `HTTPURLResponse`.
case invalidResponse(URLResponse)
/// The `URLResponse` didn't contain a URL.
case noURL
}

View File

@ -0,0 +1,51 @@
import Foundation
/// A networking service that can be used to perform HTTP requests.
///
public class HTTPService {
// MARK: Properties
/// The URL against which requests are resolved.
let baseURL: URL
/// The underlying `HTTPClient` that performs the network request.
let client: HTTPClient
/// A logger used to log HTTP request and responses.
let logger = HTTPLogger()
// MARK: Initialization
/// Initialize a `HTTPService`.
///
/// - Parameters:
/// - baseURL: The URL against which requests are resolved.
/// - client: The underlying `HTTPClient` that performs the network request.
///
public init(
baseURL: URL,
client: HTTPClient = URLSession.shared
) {
self.baseURL = baseURL
self.client = client
}
// MARK: Request Performing
/// Performs a network request.
///
/// - Parameter request: The request to perform.
/// - Returns: The response received for the request.
///
public func send<R: Request>(
_ request: R
) async throws -> R.Response where R.Response: Response {
let httpRequest = try HTTPRequest(request: request, baseURL: baseURL)
logger.logRequest(httpRequest)
let httpResponse = try await client.send(httpRequest)
logger.logResponse(httpResponse)
return try R.Response(response: httpResponse)
}
}

View File

@ -0,0 +1,43 @@
import Foundation
/// A protocol for an instance that describes an HTTP request.
///
public protocol Request {
/// The response type associated with this request.
associatedtype Response
/// The body type associated with this request. This could be `Data` or another type conforming
/// to `RequestBody` that could be converted to `Data` to include in the body of the request.
associatedtype Body: RequestBody
/// The HTTP method for the request.
var method: HTTPMethod { get }
/// The body of the request.
var body: Body? { get }
/// The URL path for this request that will be appended to the base URL.
var path: String { get }
/// A dictionary of HTTP headers to be sent in the request.
var headers: [String: String] { get }
/// A list of URL query items for the request.
var query: [URLQueryItem] { get }
}
/// This extension provides default values for the `Request` methods, which can be overridden in a
/// type conforming to the `Request` protocol.
///
public extension Request {
/// The HTTP method for the request.
var method: HTTPMethod { .get }
/// The body of the request.
var body: Data? { nil }
/// A dictionary of HTTP headers to be sent in the request.
var headers: [String: String] { [:] }
/// A list of URL query items for the request.
var query: [URLQueryItem] { [] }
}

View File

@ -0,0 +1,43 @@
import Foundation
/// A protocol for an instance containing the data for the body of a request.
///
public protocol RequestBody {
/// Additional headers to append to the request headers.
var additionalHeaders: [String: String] { get }
/// Encodes the data to be included in the body of the request.
///
/// - Returns: The encoded data to include in the body of the request.
///
func encode() throws -> Data
}
/// A protocol for a `RequestBody` that can be encoded to JSON for the body of a request.
///
public protocol JSONRequestBody: RequestBody, Encodable {
/// The `JSONEncoder` used to encode the object to include in the body of the request.
static var encoder: JSONEncoder { get }
}
public extension JSONRequestBody {
/// Additional headers to append to the request headers.
var additionalHeaders: [String: String] {
["Content-Type": "application/json"]
}
/// Encodes the data to be included in the body of the request.
///
/// - Returns: The encoded data to include in the body of the request.
///
func encode() throws -> Data {
try Self.encoder.encode(self)
}
}
/// Conforms `Data` to `RequestBody`.
///
extension Data: RequestBody {
public var additionalHeaders: [String: String] { [:] }
public func encode() throws -> Data { self }
}

View File

@ -0,0 +1,37 @@
import Foundation
/// A protocol for an instance that describes a HTTP response.
///
public protocol Response {
/// Initialize a `Response` from a `HTTPResponse`.
///
/// Typically, this is where the raw `HTTPResponse` would be decoded into an app model.
///
/// - Parameter response: The `HTTPResponse` used to initialize the `Response`.
///
init(response: HTTPResponse) throws
}
/// A protocol for a `Response` containing JSON.
///
public protocol JSONResponse: Response, Codable {
/// A JSON decoder used to decode this response.
static var decoder: JSONDecoder { get }
}
public extension JSONResponse {
/// Initialize a `JSONResponse` from a `HTTPResponse`.
///
/// - Parameter response: The `HTTPResponse` used to initialize the `Response.
///
init(response: HTTPResponse) throws {
self = try Self.decoder.decode(Self.self, from: response.body)
}
}
extension Array: Response where Element: JSONResponse {}
extension Array: JSONResponse where Element: JSONResponse {
public static var decoder: JSONDecoder {
Element.decoder
}
}

View File

@ -0,0 +1,87 @@
import XCTest
@testable import Networking
class URLSessionHTTPClientTests: XCTestCase {
var subject: URLSession!
override func setUp() {
super.setUp()
let configuration = URLSessionConfiguration.default
configuration.protocolClasses = [MockURLProtocol.self]
subject = URLSession(configuration: configuration)
}
override func tearDown() {
super.tearDown()
subject = nil
URLProtocolMocking.reset()
}
/// `send(_:)` performs the request and returns the response for a 200 status request.
func testSendSuccess200() async throws {
let urlResponse = HTTPURLResponse(
url: URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)!
URLProtocolMocking.mock(
HTTPRequest.default.url,
with: .success((urlResponse, "response data".data(using: .utf8)!))
)
let httpResponse = try await subject.send(.default)
XCTAssertEqual(
try String(data: XCTUnwrap(httpResponse.body), encoding: .utf8),
"response data"
)
XCTAssertEqual(httpResponse.headers, ["Content-Type": "application/json"])
XCTAssertEqual(httpResponse.statusCode, 200)
XCTAssertEqual(httpResponse.url, URL(string: "https://example.com")!)
}
/// `send(_:)` performs the request and returns the response for a 500 status request.
func testSendSuccess500() async throws {
let urlResponse = HTTPURLResponse(
url: URL(string: "https://example.com")!,
statusCode: 500,
httpVersion: nil,
headerFields: nil
)!
URLProtocolMocking.mock(
HTTPRequest.default.url,
with: .success((urlResponse, Data()))
)
let httpResponse = try await subject.send(.default)
XCTAssertEqual(httpResponse.body, Data())
XCTAssertEqual(httpResponse.headers, [:])
XCTAssertEqual(httpResponse.statusCode, 500)
XCTAssertEqual(httpResponse.url, URL(string: "https://example.com")!)
}
/// `send(_:)` performs the request and throws an error if one occurs.
func testSendError() async throws {
URLProtocolMocking.mock(
HTTPRequest.default.url,
with: .failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil))
)
do {
_ = try await subject.send(.default)
XCTFail("Expected send(_:) to throw an error.")
} catch {
let nsError = error as NSError
XCTAssertEqual(nsError.domain, NSURLErrorDomain)
XCTAssertEqual(nsError.code, NSURLErrorTimedOut)
}
}
}

View File

@ -0,0 +1,67 @@
import XCTest
@testable import Networking
class HTTPRequestTests: XCTestCase {
struct TestRequest: Request {
typealias Response = String // swiftlint:disable:this nesting
let body: Data? = "body data".data(using: .utf8)
let headers = ["Content-Type": "application/json"]
let method = HTTPMethod.get
let path = "/test"
let query = [URLQueryItem(name: "foo", value: "bar")]
}
/// The initializer provides default values.
func testInitDefaultValues() {
let subject = HTTPRequest(url: URL(string: "https://example.com")!)
XCTAssertNil(subject.body)
XCTAssertEqual(subject.headers, [:])
XCTAssertEqual(subject.method, .get)
XCTAssertEqual(subject.url, URL(string: "https://example.com")!)
}
/// The initializer sets the item's properties.
func testInit() throws {
let subject = HTTPRequest(
url: URL(string: "https://example.com/json")!,
method: .post,
headers: [
"Content-Type": "application/json",
"Authorization": "🔒",
],
body: "top secret".data(using: .utf8)!
)
try XCTAssertEqual(
String(data: XCTUnwrap(subject.body), encoding: .utf8),
"top secret"
)
XCTAssertEqual(
subject.headers,
[
"Content-Type": "application/json",
"Authorization": "🔒",
]
)
XCTAssertEqual(subject.method, .post)
XCTAssertEqual(subject.url, URL(string: "https://example.com/json")!)
}
/// `init(request:baseURL)` builds a `HTTPRequest` from a `Request` object.
func testInitRequest() throws {
let subject = try HTTPRequest(
request: TestRequest(),
baseURL: URL(string: "https://example.com/")!
)
XCTAssertEqual(
try String(data: XCTUnwrap(subject.body), encoding: .utf8),
"body data"
)
XCTAssertEqual(subject.headers, ["Content-Type": "application/json"])
XCTAssertEqual(subject.method, .get)
XCTAssertEqual(subject.url, URL(string: "https://example.com/test?foo=bar")!)
}
}

View File

@ -0,0 +1,68 @@
import XCTest
@testable import Networking
class HTTPResponseTests: XCTestCase {
/// The initializer sets the item's properties.
func testInit() {
let subject = HTTPResponse(
url: URL(string: "https://example.com")!,
statusCode: 200,
headers: [:],
body: Data(),
requestID: UUID()
)
XCTAssertEqual(subject.body, Data())
XCTAssertEqual(subject.headers, [:])
XCTAssertEqual(subject.statusCode, 200)
XCTAssertEqual(subject.url, URL(string: "https://example.com")!)
}
/// The initializer sets the item's properties with a `HTTPURLResponse`.
func testInitResponse() throws {
let subject = try HTTPResponse(
data: "response body".data(using: .utf8)!,
response: XCTUnwrap(HTTPURLResponse(
url: URL(string: "https://example.com")!,
statusCode: 200,
httpVersion: nil,
headerFields: ["Content-Type": "application/json"]
)),
request: .default
)
XCTAssertEqual(
try String(data: XCTUnwrap(subject.body), encoding: .utf8),
"response body"
)
XCTAssertEqual(subject.headers, ["Content-Type": "application/json"])
XCTAssertEqual(subject.statusCode, 200)
XCTAssertEqual(subject.url, URL(string: "https://example.com")!)
}
/// Initializing an `HTTPResponse` with an `URLResponse` vs a `HTTPURLResponse` throws an error.
func testInitWithURLResponseThrowsError() {
let urlResponse = URLResponse(
url: URL(string: "https://example.com")!,
mimeType: nil,
expectedContentLength: 0,
textEncodingName: nil
)
XCTAssertThrowsError(
try HTTPResponse(
data: Data(),
response: urlResponse,
request: .default
),
"Expected a HTTPResponseError.invalidResponse error to be thrown"
) { error in
XCTAssertTrue(error is HTTPResponseError)
XCTAssertEqual(
error as? HTTPResponseError,
HTTPResponseError.invalidResponse(urlResponse)
)
}
}
}

View File

@ -0,0 +1,50 @@
import XCTest
@testable import Networking
class HTTPServiceTests: XCTestCase {
var client: MockHTTPClient!
var subject: HTTPService!
override func setUp() {
super.setUp()
client = MockHTTPClient()
subject = HTTPService(
baseURL: URL(string: "https://example.com")!,
client: client
)
}
override func tearDown() {
super.tearDown()
client = nil
subject = nil
}
/// `send(_:)` forwards the request to the client and returns the response.
func testSendRequest() async throws {
let httpResponse = HTTPResponse.success()
client.result = .success(httpResponse)
let response = try await subject.send(TestRequest())
XCTAssertEqual(response.httpResponse, httpResponse)
}
/// `send(_:)` forwards the request to the client and throws if an error occurs.
func testSendRequestError() async {
client.result = .failure(RequestError())
do {
_ = try await subject.send(TestRequest())
XCTFail("Expected send(_:) to throw an error")
} catch {
XCTAssertTrue(error is RequestError)
}
}
}
private struct RequestError: Error {}

View File

@ -0,0 +1,31 @@
import XCTest
@testable import Networking
class RequestBodyTests: XCTestCase {
struct TestRequestBodyJSON: JSONRequestBody {
static var encoder = JSONEncoder()
let name = "john"
}
/// `JSONRequestBody` can encode the JSON request body and provide additional headers.
func testRequestBodyJSON() throws {
let subject = TestRequestBodyJSON()
XCTAssertEqual(subject.additionalHeaders, ["Content-Type": "application/json"])
let encodedData = try subject.encode()
XCTAssertEqual(String(data: encodedData, encoding: .utf8), #"{"name":"john"}"#)
}
/// Test that `Data` conforms to `RequestBody` and can be used directly.
func testRequestBodyData() throws {
let data = try XCTUnwrap("💾".data(using: .utf8))
let subject: RequestBody = data
XCTAssertEqual(subject.additionalHeaders, [:])
XCTAssertEqual(try subject.encode(), data)
}
}

View File

@ -0,0 +1,19 @@
import XCTest
@testable import Networking
class RequestTests: XCTestCase {
struct DefaultRequest: Request {
typealias Response = String // swiftlint:disable:this nesting
var path: String = "/path"
}
/// `Request` default.
func testRequest() {
let request = DefaultRequest()
XCTAssertEqual(request.method, .get)
XCTAssertNil(request.body)
XCTAssertEqual(request.headers, [:])
XCTAssertEqual(request.query, [])
}
}

View File

@ -0,0 +1,32 @@
import XCTest
@testable import Networking
class ResponseTests: XCTestCase {
/// Test creating a `Response` from JSON.
func testJSONResponse() throws {
let httpResponse = HTTPResponse(
url: URL(string: "http://example.com")!,
statusCode: 200,
headers: [:],
body: "{ \"field\": \"value\" }".data(using: .utf8)!,
requestID: UUID()
)
let response = try TestJSONResponse(response: httpResponse)
XCTAssertEqual(response.field, "value")
}
/// Test creating a `Response` from a JSON array.
func testJSONArray() throws {
let httpResponse = HTTPResponse(
url: URL(string: "http://example.com")!,
statusCode: 200,
headers: [:],
body: "[{ \"field\": \"value\" }]".data(using: .utf8)!,
requestID: UUID()
)
let response = try [TestJSONResponse](response: httpResponse)
XCTAssertEqual(response.count, 1)
XCTAssertEqual(response[0].field, "value")
}
}

View File

@ -0,0 +1,7 @@
import Foundation
@testable import Networking
extension HTTPRequest {
static let `default` = HTTPRequest(url: URL(string: "https://www.example.com")!)
}

View File

@ -0,0 +1,35 @@
import Foundation
@testable import Networking
extension HTTPResponse {
static func success(
string: String = "http://example.com",
statusCode: Int = 200,
headers: [String: String] = [:],
body: Data = Data()
) -> HTTPResponse {
HTTPResponse(
url: URL(string: string)!,
statusCode: statusCode,
headers: headers,
body: body,
requestID: UUID()
)
}
static func failure(
string: String = "http://example.com",
statusCode: Int = 500,
headers: [String: String] = [:],
body: Data = Data()
) -> HTTPResponse {
HTTPResponse(
url: URL(string: string)!,
statusCode: statusCode,
headers: headers,
body: body,
requestID: UUID()
)
}
}

View File

@ -0,0 +1,16 @@
@testable import Networking
class MockHTTPClient: HTTPClient {
var result: Result<HTTPResponse, Error>?
func send(_ request: HTTPRequest) async throws -> HTTPResponse {
guard let result else {
throw MockClientError.noResultForRequest
}
return try result.get()
}
}
enum MockClientError: Error {
case noResultForRequest
}

View File

@ -0,0 +1,38 @@
import Foundation
/// A mock `URLProtocol` used to mock networking requests using `URLSession`.
///
class MockURLProtocol: URLProtocol {
override class func canInit(with request: URLRequest) -> Bool {
guard let url = request.url,
URLProtocolMocking.response(for: url) != nil
else {
return false
}
return true
}
override class func canonicalRequest(for request: URLRequest) -> URLRequest {
request
}
override func startLoading() {
guard let url = request.url,
let result = URLProtocolMocking.response(for: url)
else {
return
}
switch result {
case let .success((response, data)):
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
case let .failure(error):
client?.urlProtocol(self, didFailWithError: error)
}
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}

View File

@ -0,0 +1,3 @@
enum TestError: Error, Equatable {
case badResponse
}

View File

@ -0,0 +1,49 @@
import Foundation
@testable import Networking
struct TestRequest: Request {
typealias Response = TestResponse
let path = "/test"
}
struct TestResponse: Response {
let httpResponse: HTTPResponse
init(response: HTTPResponse) throws {
httpResponse = response
}
}
struct TestJSONRequest: Request {
typealias Response = TestJSONResponse
let path = "/test.json"
}
struct TestJSONResponse: JSONResponse {
static var decoder: JSONDecoder { JSONDecoder() }
var field: String
}
struct TestValidatingRequest: Request {
typealias Response = TestResponse
let path = "/test"
func validate(_ response: HTTPResponse) throws -> Result<HTTPResponse, Error> {
throw TestError.badResponse
}
}
struct TestJSONRequestBody: JSONRequestBody {
static var encoder: JSONEncoder { JSONEncoder() }
let field: String
}
struct TestJSONBodyRequest: Request {
typealias Response = TestResponse
typealias Body = TestJSONRequestBody
let path = "/test"
var body: TestJSONRequestBody?
}

View File

@ -0,0 +1,85 @@
import Foundation
/// An object that manages mock responses for `MockURLProtocol`.
///
class URLProtocolMocking {
typealias Response = Result<(URLResponse, Data), Error>
// MARK: Properties
/// The singleton mocking instance. Only one object should be used at a time since only one
/// URL protocol can be registered at a time.
private static let shared = URLProtocolMocking()
// MARK: Private properties
/// The queue used to synchronize access to the instance across threads.
private let queue = DispatchQueue(label: "URLProtocolMocking")
/// The responses currently configured for mocking, by URL.
private var responses: [URL: Response] = [:]
// MARK: Static Methods
/// Stub out requests for the given url with a fake response.
///
/// - Parameters:
/// - url: The url which will be matched against incoming requests.
/// - response: The mock response to return when a request is made.
///
static func mock(_ url: URL, with response: Response) {
shared.mock(url, with: response)
}
/// Resets all mocks on the singleton instance.
///
/// Use this during tearDown() to remove all previously configured mocks.
///
static func reset() {
shared.reset()
}
/// Returns the mocked response for the given url.
///
/// - Parameter url: The url of an incoming request.
/// - Returns: A response result to use for mocking or nil if the url is not matched.
///
static func response(for url: URL) -> Response? {
shared.response(for: url)
}
// MARK: Private
/// Stub out requests for the given url with a fake response.
///
/// - Parameters:
/// - url: The url which will be matched against incoming requests.
/// - response: The mock response to return when a request is made.
///
private func mock(_ url: URL, with response: Response) {
queue.sync {
responses[url] = response
}
}
/// Resets all mocks.
///
/// Use this during tearDown() to remove all previously configured mocks.
///
private func reset() {
queue.sync {
responses.removeAll()
}
}
/// Returns the mocked response for the given url.
///
/// - Parameter url: The url of an incoming request.
/// - Returns: A response result to use for mocking or nil if the url is not matched.
///
private func response(for url: URL) -> Response? {
queue.sync {
responses[url]
}
}
}

View File

@ -18,6 +18,8 @@ packages:
Firebase:
url: https://github.com/firebase/firebase-ios-sdk
exactVersion: 10.13.0
Networking:
path: Networking
SnapshotTesting:
url: https://github.com/pointfreeco/swift-snapshot-testing
exactVersion: 1.11.1
@ -40,6 +42,7 @@ schemes:
- BitwardenAutoFillExtensionTests
- BitwardenShareExtensionTests
- BitwardenSharedTests
- package: Networking/NetworkingTests
BitwardenActionExtension:
build:
targets:
@ -258,10 +261,11 @@ targets:
randomExecutionOrder: true
BitwardenShared:
type: library.static
type: framework
platform: iOS
settings:
base:
APPLICATION_EXTENSION_API_ONLY: true
INFOPLIST_FILE: BitwardenShared/Application/Support/Info.plist
sources:
- path: BitwardenShared
@ -269,8 +273,7 @@ targets:
- "**/*Tests.*"
- "**/TestHelpers/*"
dependencies:
- package: Firebase
product: FirebaseCrashlytics
- package: Networking
BitwardenSharedTests:
type: bundle.unit-test
platform: iOS