mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-12 18:30:41 -06:00
BIT-59: Add initial networking layer (#9)
This commit is contained in:
parent
090f1a07fd
commit
adf0a24d73
@ -12,6 +12,8 @@
|
|||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>$(PRODUCT_NAME)</string>
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>FMWK</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
|
|||||||
32
Networking/.swiftpm/Networking.xctestplan
Normal file
32
Networking/.swiftpm/Networking.xctestplan
Normal 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
|
||||||
|
}
|
||||||
7
Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
@ -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
23
Networking/Package.swift
Normal 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"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@ -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!
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Networking/Sources/Networking/HTTPClient.swift
Normal file
10
Networking/Sources/Networking/HTTPClient.swift
Normal 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
|
||||||
|
}
|
||||||
48
Networking/Sources/Networking/HTTPLogger.swift
Normal file
48
Networking/Sources/Networking/HTTPLogger.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
Networking/Sources/Networking/HTTPMethod.swift
Normal file
23
Networking/Sources/Networking/HTTPMethod.swift
Normal 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")
|
||||||
|
}
|
||||||
83
Networking/Sources/Networking/HTTPRequest.swift
Normal file
83
Networking/Sources/Networking/HTTPRequest.swift
Normal 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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
64
Networking/Sources/Networking/HTTPResponse.swift
Normal file
64
Networking/Sources/Networking/HTTPResponse.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
10
Networking/Sources/Networking/HTTPResponseError.swift
Normal file
10
Networking/Sources/Networking/HTTPResponseError.swift
Normal 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
|
||||||
|
}
|
||||||
51
Networking/Sources/Networking/HTTPService.swift
Normal file
51
Networking/Sources/Networking/HTTPService.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
43
Networking/Sources/Networking/Request.swift
Normal file
43
Networking/Sources/Networking/Request.swift
Normal 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] { [] }
|
||||||
|
}
|
||||||
43
Networking/Sources/Networking/RequestBody.swift
Normal file
43
Networking/Sources/Networking/RequestBody.swift
Normal 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 }
|
||||||
|
}
|
||||||
37
Networking/Sources/Networking/Response.swift
Normal file
37
Networking/Sources/Networking/Response.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Networking/Tests/NetworkingTests/HTTPRequestTests.swift
Normal file
67
Networking/Tests/NetworkingTests/HTTPRequestTests.swift
Normal 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")!)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
Networking/Tests/NetworkingTests/HTTPResponseTests.swift
Normal file
68
Networking/Tests/NetworkingTests/HTTPResponseTests.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
Networking/Tests/NetworkingTests/HTTPServiceTests.swift
Normal file
50
Networking/Tests/NetworkingTests/HTTPServiceTests.swift
Normal 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 {}
|
||||||
31
Networking/Tests/NetworkingTests/RequestBodyTests.swift
Normal file
31
Networking/Tests/NetworkingTests/RequestBodyTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Networking/Tests/NetworkingTests/RequestTests.swift
Normal file
19
Networking/Tests/NetworkingTests/RequestTests.swift
Normal 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, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Networking/Tests/NetworkingTests/ResponseTests.swift
Normal file
32
Networking/Tests/NetworkingTests/ResponseTests.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@testable import Networking
|
||||||
|
|
||||||
|
extension HTTPRequest {
|
||||||
|
static let `default` = HTTPRequest(url: URL(string: "https://www.example.com")!)
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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() {}
|
||||||
|
}
|
||||||
3
Networking/Tests/NetworkingTests/Support/TestError.swift
Normal file
3
Networking/Tests/NetworkingTests/Support/TestError.swift
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
enum TestError: Error, Equatable {
|
||||||
|
case badResponse
|
||||||
|
}
|
||||||
49
Networking/Tests/NetworkingTests/Support/TestRequest.swift
Normal file
49
Networking/Tests/NetworkingTests/Support/TestRequest.swift
Normal 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?
|
||||||
|
}
|
||||||
@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,8 @@ packages:
|
|||||||
Firebase:
|
Firebase:
|
||||||
url: https://github.com/firebase/firebase-ios-sdk
|
url: https://github.com/firebase/firebase-ios-sdk
|
||||||
exactVersion: 10.13.0
|
exactVersion: 10.13.0
|
||||||
|
Networking:
|
||||||
|
path: Networking
|
||||||
SnapshotTesting:
|
SnapshotTesting:
|
||||||
url: https://github.com/pointfreeco/swift-snapshot-testing
|
url: https://github.com/pointfreeco/swift-snapshot-testing
|
||||||
exactVersion: 1.11.1
|
exactVersion: 1.11.1
|
||||||
@ -40,6 +42,7 @@ schemes:
|
|||||||
- BitwardenAutoFillExtensionTests
|
- BitwardenAutoFillExtensionTests
|
||||||
- BitwardenShareExtensionTests
|
- BitwardenShareExtensionTests
|
||||||
- BitwardenSharedTests
|
- BitwardenSharedTests
|
||||||
|
- package: Networking/NetworkingTests
|
||||||
BitwardenActionExtension:
|
BitwardenActionExtension:
|
||||||
build:
|
build:
|
||||||
targets:
|
targets:
|
||||||
@ -258,10 +261,11 @@ targets:
|
|||||||
randomExecutionOrder: true
|
randomExecutionOrder: true
|
||||||
|
|
||||||
BitwardenShared:
|
BitwardenShared:
|
||||||
type: library.static
|
type: framework
|
||||||
platform: iOS
|
platform: iOS
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
|
APPLICATION_EXTENSION_API_ONLY: true
|
||||||
INFOPLIST_FILE: BitwardenShared/Application/Support/Info.plist
|
INFOPLIST_FILE: BitwardenShared/Application/Support/Info.plist
|
||||||
sources:
|
sources:
|
||||||
- path: BitwardenShared
|
- path: BitwardenShared
|
||||||
@ -269,8 +273,7 @@ targets:
|
|||||||
- "**/*Tests.*"
|
- "**/*Tests.*"
|
||||||
- "**/TestHelpers/*"
|
- "**/TestHelpers/*"
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: Firebase
|
- package: Networking
|
||||||
product: FirebaseCrashlytics
|
|
||||||
BitwardenSharedTests:
|
BitwardenSharedTests:
|
||||||
type: bundle.unit-test
|
type: bundle.unit-test
|
||||||
platform: iOS
|
platform: iOS
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user