diff --git a/BitwardenShared/Application/Support/Info.plist b/BitwardenShared/Application/Support/Info.plist
index 7ad38b4b0..323e5ecfc 100644
--- a/BitwardenShared/Application/Support/Info.plist
+++ b/BitwardenShared/Application/Support/Info.plist
@@ -12,6 +12,8 @@
6.0
CFBundleName
$(PRODUCT_NAME)
+ CFBundlePackageType
+ FMWK
CFBundleShortVersionString
1.0
CFBundleVersion
diff --git a/Networking/.swiftpm/Networking.xctestplan b/Networking/.swiftpm/Networking.xctestplan
new file mode 100644
index 000000000..09a142615
--- /dev/null
+++ b/Networking/.swiftpm/Networking.xctestplan
@@ -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
+}
diff --git a/Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 000000000..919434a62
--- /dev/null
+++ b/Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/Networking/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme b/Networking/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme
new file mode 100644
index 000000000..0b69c3dfc
--- /dev/null
+++ b/Networking/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Networking/Package.swift b/Networking/Package.swift
new file mode 100644
index 000000000..782715795
--- /dev/null
+++ b/Networking/Package.swift
@@ -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"]
+ ),
+ ]
+)
diff --git a/Networking/Sources/Networking/Extensions/Logger+Networking.swift b/Networking/Sources/Networking/Extensions/Logger+Networking.swift
new file mode 100644
index 000000000..aa7a20830
--- /dev/null
+++ b/Networking/Sources/Networking/Extensions/Logger+Networking.swift
@@ -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!
+}
diff --git a/Networking/Sources/Networking/Extensions/URLSession+HTTPClient.swift b/Networking/Sources/Networking/Extensions/URLSession+HTTPClient.swift
new file mode 100644
index 000000000..8c2f0f739
--- /dev/null
+++ b/Networking/Sources/Networking/Extensions/URLSession+HTTPClient.swift
@@ -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)
+ }
+}
diff --git a/Networking/Sources/Networking/HTTPClient.swift b/Networking/Sources/Networking/HTTPClient.swift
new file mode 100644
index 000000000..0cfcb0367
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPClient.swift
@@ -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
+}
diff --git a/Networking/Sources/Networking/HTTPLogger.swift b/Networking/Sources/Networking/HTTPLogger.swift
new file mode 100644
index 000000000..cdc3d9ceb
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPLogger.swift
@@ -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
+ }
+}
diff --git a/Networking/Sources/Networking/HTTPMethod.swift b/Networking/Sources/Networking/HTTPMethod.swift
new file mode 100644
index 000000000..73e710381
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPMethod.swift
@@ -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")
+}
diff --git a/Networking/Sources/Networking/HTTPRequest.swift b/Networking/Sources/Networking/HTTPRequest.swift
new file mode 100644
index 000000000..044dc9b42
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPRequest.swift
@@ -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(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()
+ )
+ }
+}
diff --git a/Networking/Sources/Networking/HTTPResponse.swift b/Networking/Sources/Networking/HTTPResponse.swift
new file mode 100644
index 000000000..cecc81024
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPResponse.swift
@@ -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
+ }
+}
diff --git a/Networking/Sources/Networking/HTTPResponseError.swift b/Networking/Sources/Networking/HTTPResponseError.swift
new file mode 100644
index 000000000..a24b363b0
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPResponseError.swift
@@ -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
+}
diff --git a/Networking/Sources/Networking/HTTPService.swift b/Networking/Sources/Networking/HTTPService.swift
new file mode 100644
index 000000000..e8f0fb64d
--- /dev/null
+++ b/Networking/Sources/Networking/HTTPService.swift
@@ -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(
+ _ 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)
+ }
+}
diff --git a/Networking/Sources/Networking/Request.swift b/Networking/Sources/Networking/Request.swift
new file mode 100644
index 000000000..912d21a81
--- /dev/null
+++ b/Networking/Sources/Networking/Request.swift
@@ -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] { [] }
+}
diff --git a/Networking/Sources/Networking/RequestBody.swift b/Networking/Sources/Networking/RequestBody.swift
new file mode 100644
index 000000000..aea878b56
--- /dev/null
+++ b/Networking/Sources/Networking/RequestBody.swift
@@ -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 }
+}
diff --git a/Networking/Sources/Networking/Response.swift b/Networking/Sources/Networking/Response.swift
new file mode 100644
index 000000000..59a3c4f18
--- /dev/null
+++ b/Networking/Sources/Networking/Response.swift
@@ -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
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/Extensions/URLSessionHTTPClientTests.swift b/Networking/Tests/NetworkingTests/Extensions/URLSessionHTTPClientTests.swift
new file mode 100644
index 000000000..68f00ca6f
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Extensions/URLSessionHTTPClientTests.swift
@@ -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)
+ }
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/HTTPRequestTests.swift b/Networking/Tests/NetworkingTests/HTTPRequestTests.swift
new file mode 100644
index 000000000..6265ebc77
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/HTTPRequestTests.swift
@@ -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")!)
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/HTTPResponseTests.swift b/Networking/Tests/NetworkingTests/HTTPResponseTests.swift
new file mode 100644
index 000000000..4daef4c55
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/HTTPResponseTests.swift
@@ -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)
+ )
+ }
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/HTTPServiceTests.swift b/Networking/Tests/NetworkingTests/HTTPServiceTests.swift
new file mode 100644
index 000000000..9cfe8a62c
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/HTTPServiceTests.swift
@@ -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 {}
diff --git a/Networking/Tests/NetworkingTests/RequestBodyTests.swift b/Networking/Tests/NetworkingTests/RequestBodyTests.swift
new file mode 100644
index 000000000..651bcbe09
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/RequestBodyTests.swift
@@ -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)
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/RequestTests.swift b/Networking/Tests/NetworkingTests/RequestTests.swift
new file mode 100644
index 000000000..e28e6f44a
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/RequestTests.swift
@@ -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, [])
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/ResponseTests.swift b/Networking/Tests/NetworkingTests/ResponseTests.swift
new file mode 100644
index 000000000..88d271cbe
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/ResponseTests.swift
@@ -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")
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/Support/HTTPRequest+Fixtures.swift b/Networking/Tests/NetworkingTests/Support/HTTPRequest+Fixtures.swift
new file mode 100644
index 000000000..13419a3c1
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/HTTPRequest+Fixtures.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+@testable import Networking
+
+extension HTTPRequest {
+ static let `default` = HTTPRequest(url: URL(string: "https://www.example.com")!)
+}
diff --git a/Networking/Tests/NetworkingTests/Support/HTTPResponse+Fixtures.swift b/Networking/Tests/NetworkingTests/Support/HTTPResponse+Fixtures.swift
new file mode 100644
index 000000000..5c6bbd1c9
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/HTTPResponse+Fixtures.swift
@@ -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()
+ )
+ }
+}
diff --git a/Networking/Tests/NetworkingTests/Support/MockHTTPClient.swift b/Networking/Tests/NetworkingTests/Support/MockHTTPClient.swift
new file mode 100644
index 000000000..06fff69aa
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/MockHTTPClient.swift
@@ -0,0 +1,16 @@
+@testable import Networking
+
+class MockHTTPClient: HTTPClient {
+ var result: Result?
+
+ func send(_ request: HTTPRequest) async throws -> HTTPResponse {
+ guard let result else {
+ throw MockClientError.noResultForRequest
+ }
+ return try result.get()
+ }
+}
+
+enum MockClientError: Error {
+ case noResultForRequest
+}
diff --git a/Networking/Tests/NetworkingTests/Support/MockURLProtocol.swift b/Networking/Tests/NetworkingTests/Support/MockURLProtocol.swift
new file mode 100644
index 000000000..4680759f5
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/MockURLProtocol.swift
@@ -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() {}
+}
diff --git a/Networking/Tests/NetworkingTests/Support/TestError.swift b/Networking/Tests/NetworkingTests/Support/TestError.swift
new file mode 100644
index 000000000..201064121
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/TestError.swift
@@ -0,0 +1,3 @@
+enum TestError: Error, Equatable {
+ case badResponse
+}
diff --git a/Networking/Tests/NetworkingTests/Support/TestRequest.swift b/Networking/Tests/NetworkingTests/Support/TestRequest.swift
new file mode 100644
index 000000000..fdae8fd18
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/TestRequest.swift
@@ -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 {
+ 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?
+}
diff --git a/Networking/Tests/NetworkingTests/Support/URLProtocolMocking.swift b/Networking/Tests/NetworkingTests/Support/URLProtocolMocking.swift
new file mode 100644
index 000000000..9752358fc
--- /dev/null
+++ b/Networking/Tests/NetworkingTests/Support/URLProtocolMocking.swift
@@ -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]
+ }
+ }
+}
diff --git a/project.yml b/project.yml
index 8e6285a17..fa62816ca 100644
--- a/project.yml
+++ b/project.yml
@@ -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