From 9bdf0ee19c6ff086c2a7f004bdc04c820c95261c Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Fri, 18 Aug 2023 09:50:39 -0500 Subject: [PATCH 1/4] BIT-59: Add initial networking layer --- .../Application/Support/Info.plist | 2 + Networking/.swiftpm/Networking.xctestplan | 32 ++++++ .../contents.xcworkspacedata | 7 ++ .../xcschemes/Networking.xcscheme | 97 +++++++++++++++++++ Networking/Package.swift | 23 +++++ .../Extensions/Logger+Networking.swift | 10 ++ .../Extensions/URLSession+HTTPClient.swift | 19 ++++ .../Sources/Networking/HTTPClient.swift | 10 ++ .../Sources/Networking/HTTPLogger.swift | 48 +++++++++ .../Sources/Networking/HTTPMethod.swift | 23 +++++ .../Sources/Networking/HTTPRequest.swift | 83 ++++++++++++++++ .../Sources/Networking/HTTPResponse.swift | 64 ++++++++++++ .../Networking/HTTPResponseError.swift | 10 ++ .../Sources/Networking/HTTPService.swift | 51 ++++++++++ Networking/Sources/Networking/Request.swift | 40 ++++++++ .../Sources/Networking/RequestBody.swift | 43 ++++++++ Networking/Sources/Networking/Response.swift | 37 +++++++ .../URLSessionHTTPClientTests.swift | 87 +++++++++++++++++ .../NetworkingTests/HTTPRequestTests.swift | 67 +++++++++++++ .../NetworkingTests/HTTPResponseTests.swift | 68 +++++++++++++ .../NetworkingTests/HTTPServiceTests.swift | 50 ++++++++++ .../NetworkingTests/RequestBodyTests.swift | 31 ++++++ .../Tests/NetworkingTests/RequestTests.swift | 19 ++++ .../Tests/NetworkingTests/ResponseTests.swift | 32 ++++++ .../Support/HTTPRequest+Fixtures.swift | 7 ++ .../Support/HTTPResponse+Fixtures.swift | 35 +++++++ .../Support/MockHTTPClient.swift | 16 +++ .../Support/MockURLProtocol.swift | 38 ++++++++ .../NetworkingTests/Support/TestError.swift | 3 + .../NetworkingTests/Support/TestRequest.swift | 49 ++++++++++ .../Support/URLProtocolMocking.swift | 85 ++++++++++++++++ project.yml | 9 +- 32 files changed, 1192 insertions(+), 3 deletions(-) create mode 100644 Networking/.swiftpm/Networking.xctestplan create mode 100644 Networking/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 Networking/.swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme create mode 100644 Networking/Package.swift create mode 100644 Networking/Sources/Networking/Extensions/Logger+Networking.swift create mode 100644 Networking/Sources/Networking/Extensions/URLSession+HTTPClient.swift create mode 100644 Networking/Sources/Networking/HTTPClient.swift create mode 100644 Networking/Sources/Networking/HTTPLogger.swift create mode 100644 Networking/Sources/Networking/HTTPMethod.swift create mode 100644 Networking/Sources/Networking/HTTPRequest.swift create mode 100644 Networking/Sources/Networking/HTTPResponse.swift create mode 100644 Networking/Sources/Networking/HTTPResponseError.swift create mode 100644 Networking/Sources/Networking/HTTPService.swift create mode 100644 Networking/Sources/Networking/Request.swift create mode 100644 Networking/Sources/Networking/RequestBody.swift create mode 100644 Networking/Sources/Networking/Response.swift create mode 100644 Networking/Tests/NetworkingTests/Extensions/URLSessionHTTPClientTests.swift create mode 100644 Networking/Tests/NetworkingTests/HTTPRequestTests.swift create mode 100644 Networking/Tests/NetworkingTests/HTTPResponseTests.swift create mode 100644 Networking/Tests/NetworkingTests/HTTPServiceTests.swift create mode 100644 Networking/Tests/NetworkingTests/RequestBodyTests.swift create mode 100644 Networking/Tests/NetworkingTests/RequestTests.swift create mode 100644 Networking/Tests/NetworkingTests/ResponseTests.swift create mode 100644 Networking/Tests/NetworkingTests/Support/HTTPRequest+Fixtures.swift create mode 100644 Networking/Tests/NetworkingTests/Support/HTTPResponse+Fixtures.swift create mode 100644 Networking/Tests/NetworkingTests/Support/MockHTTPClient.swift create mode 100644 Networking/Tests/NetworkingTests/Support/MockURLProtocol.swift create mode 100644 Networking/Tests/NetworkingTests/Support/TestError.swift create mode 100644 Networking/Tests/NetworkingTests/Support/TestRequest.swift create mode 100644 Networking/Tests/NetworkingTests/Support/URLProtocolMocking.swift 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..908f308db --- /dev/null +++ b/Networking/Sources/Networking/Request.swift @@ -0,0 +1,40 @@ +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 } +} + +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 From cbd674e51629f176d204abc68f8c6b4a6997e5e0 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Thu, 24 Aug 2023 15:12:57 -0500 Subject: [PATCH 2/4] BIT-59: Add request extension comment --- Networking/Sources/Networking/Request.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Networking/Sources/Networking/Request.swift b/Networking/Sources/Networking/Request.swift index 908f308db..912d21a81 100644 --- a/Networking/Sources/Networking/Request.swift +++ b/Networking/Sources/Networking/Request.swift @@ -25,6 +25,9 @@ public protocol 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 } From 4ad21870cba4639ceeef4d177a2f40aa071b7d23 Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Wed, 23 Aug 2023 09:23:50 -0500 Subject: [PATCH 3/4] BIT-91: Stub out API services --- .../Services/Auth/API/AccountAPIService.swift | 5 +++ .../Services/Auth/API/AuditAPIService.swift | 5 +++ .../Services/Auth/API/AuthAPIService.swift | 5 +++ .../Services/Auth/API/DeviceAPIService.swift | 5 +++ .../Auth/API/OrganizationAPIService.swift | 5 +++ .../Auth/API/OrganizationUserAPIService.swift | 5 +++ .../API/PasswordlessLoginAPIService.swift | 5 +++ .../Auth/API/TwoFactorAPIService.swift | 5 +++ .../Services/Platform/API/APIService.swift | 30 +++++++++++++ .../Platform/API/APIServiceTests.swift | 32 +++++++++++++ .../Platform/API/ConfigAPIService.swift | 5 +++ .../Platform/API/EventAPIService.swift | 5 +++ .../API/TestHelpers/APITestData.swift | 25 +++++++++++ .../API/TestHelpers/MockHTTPClient.swift | 45 +++++++++++++++++++ .../API/TestHelpers/Result+HTTPResponse.swift | 34 ++++++++++++++ .../Services/Tools/API/SendAPIService.swift | 5 +++ .../Vault/API/AttachmentAPIService.swift | 5 +++ .../Services/Vault/API/CipherAPIService.swift | 5 +++ .../Services/Vault/API/FolderAPIService.swift | 5 +++ .../Services/Vault/API/SyncAPIService.swift | 5 +++ 20 files changed, 241 insertions(+) create mode 100644 BitwardenShared/Core/Services/Auth/API/AccountAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/AuditAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/AuthAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/DeviceAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/OrganizationAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/OrganizationUserAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/PasswordlessLoginAPIService.swift create mode 100644 BitwardenShared/Core/Services/Auth/API/TwoFactorAPIService.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/APIService.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/ConfigAPIService.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/EventAPIService.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/TestHelpers/APITestData.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/TestHelpers/MockHTTPClient.swift create mode 100644 BitwardenShared/Core/Services/Platform/API/TestHelpers/Result+HTTPResponse.swift create mode 100644 BitwardenShared/Core/Services/Tools/API/SendAPIService.swift create mode 100644 BitwardenShared/Core/Services/Vault/API/AttachmentAPIService.swift create mode 100644 BitwardenShared/Core/Services/Vault/API/CipherAPIService.swift create mode 100644 BitwardenShared/Core/Services/Vault/API/FolderAPIService.swift create mode 100644 BitwardenShared/Core/Services/Vault/API/SyncAPIService.swift diff --git a/BitwardenShared/Core/Services/Auth/API/AccountAPIService.swift b/BitwardenShared/Core/Services/Auth/API/AccountAPIService.swift new file mode 100644 index 000000000..352fc6d45 --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/AccountAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make account requests. +/// +protocol AccountAPIService {} + +extension APIService: AccountAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/AuditAPIService.swift b/BitwardenShared/Core/Services/Auth/API/AuditAPIService.swift new file mode 100644 index 000000000..84b889a85 --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/AuditAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make audit requests. +/// +protocol AuditAPIService {} + +extension APIService: AuditAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/AuthAPIService.swift b/BitwardenShared/Core/Services/Auth/API/AuthAPIService.swift new file mode 100644 index 000000000..c7a0b4d9d --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/AuthAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make auth requests. +/// +protocol AuthAPIService {} + +extension APIService: AuthAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/DeviceAPIService.swift b/BitwardenShared/Core/Services/Auth/API/DeviceAPIService.swift new file mode 100644 index 000000000..2fbc65e9f --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/DeviceAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make device requests. +/// +protocol DeviceAPIService {} + +extension APIService: DeviceAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/OrganizationAPIService.swift b/BitwardenShared/Core/Services/Auth/API/OrganizationAPIService.swift new file mode 100644 index 000000000..994725964 --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/OrganizationAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make organization requests. +/// +protocol OrganizationAPIService {} + +extension APIService: OrganizationAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/OrganizationUserAPIService.swift b/BitwardenShared/Core/Services/Auth/API/OrganizationUserAPIService.swift new file mode 100644 index 000000000..7f846aeb9 --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/OrganizationUserAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make organization user requests. +/// +protocol OrganizationUserAPIService {} + +extension APIService: OrganizationUserAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/PasswordlessLoginAPIService.swift b/BitwardenShared/Core/Services/Auth/API/PasswordlessLoginAPIService.swift new file mode 100644 index 000000000..2108d6ebb --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/PasswordlessLoginAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make passwordless login requests. +/// +protocol PasswordlessLoginAPIService {} + +extension APIService: PasswordlessLoginAPIService {} diff --git a/BitwardenShared/Core/Services/Auth/API/TwoFactorAPIService.swift b/BitwardenShared/Core/Services/Auth/API/TwoFactorAPIService.swift new file mode 100644 index 000000000..1c0c2fa9d --- /dev/null +++ b/BitwardenShared/Core/Services/Auth/API/TwoFactorAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make two factor auth requests. +/// +protocol TwoFactorAPIService {} + +extension APIService: TwoFactorAPIService {} diff --git a/BitwardenShared/Core/Services/Platform/API/APIService.swift b/BitwardenShared/Core/Services/Platform/API/APIService.swift new file mode 100644 index 000000000..e4fc4f79f --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/APIService.swift @@ -0,0 +1,30 @@ +import Foundation +import Networking + +/// A service used by the application to make API requests. +/// +class APIService { + // MARK: Properties + + /// The API service that is used for general requests. + let apiService: HTTPService + + /// The API service used for logging events. + let eventsService: HTTPService + + /// The API service used for user identity requests. + let identityService: HTTPService + + // MARK: Initialization + + /// Initialize an `APIService` used to make API requests. + /// + /// - Parameter client: The underlying `HTTPClient` that performs the network request. Defaults + /// to `URLSession.shared`. + /// + init(client: HTTPClient = URLSession.shared) { + apiService = HTTPService(baseURL: URL(string: "https://vault.bitwarden.com/api")!, client: client) + eventsService = HTTPService(baseURL: URL(string: "https://vault.bitwarden.com/events")!, client: client) + identityService = HTTPService(baseURL: URL(string: "https://vault.bitwarden.com/identity")!, client: client) + } +} diff --git a/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift b/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift new file mode 100644 index 000000000..35cfef965 --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift @@ -0,0 +1,32 @@ +import XCTest + +@testable import BitwardenShared +@testable import Networking + +class APIServiceTests: BitwardenTestCase { + var subject: APIService! + + override func setUp() { + super.setUp() + + subject = APIService() + } + + override func tearDown() { + super.tearDown() + + subject = nil + } + + /// `init(client:)` sets the default base URLs for the HTTP services. + func testInitDefaultURLs() async { + let apiServiceBaseURL = await subject.apiService.baseURL + XCTAssertEqual(apiServiceBaseURL, URL(string: "https://vault.bitwarden.com/api")!) + + let eventsServiceBaseURL = await subject.eventsService.baseURL + XCTAssertEqual(eventsServiceBaseURL, URL(string: "https://vault.bitwarden.com/events")!) + + let identityServiceBaseURL = await subject.identityService.baseURL + XCTAssertEqual(identityServiceBaseURL, URL(string: "https://vault.bitwarden.com/identity")!) + } +} diff --git a/BitwardenShared/Core/Services/Platform/API/ConfigAPIService.swift b/BitwardenShared/Core/Services/Platform/API/ConfigAPIService.swift new file mode 100644 index 000000000..4e4c7dfb0 --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/ConfigAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make config requests. +/// +protocol ConfigAPIService {} + +extension APIService: ConfigAPIService {} diff --git a/BitwardenShared/Core/Services/Platform/API/EventAPIService.swift b/BitwardenShared/Core/Services/Platform/API/EventAPIService.swift new file mode 100644 index 000000000..21e1a4b36 --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/EventAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make event requests. +/// +protocol EventAPIService {} + +extension APIService: EventAPIService {} diff --git a/BitwardenShared/Core/Services/Platform/API/TestHelpers/APITestData.swift b/BitwardenShared/Core/Services/Platform/API/TestHelpers/APITestData.swift new file mode 100644 index 000000000..2a28bf030 --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/TestHelpers/APITestData.swift @@ -0,0 +1,25 @@ +import Foundation + +/// A type that wraps fixture data for use in mocking API responses during tests. +/// +struct APITestData { + let data: Data + + static func loadFromBundle(resource: String, extension: String) -> APITestData { + let bundle = Bundle(for: BitwardenTestCase.self) + guard let url = bundle.url(forResource: resource, withExtension: `extension`) else { + fatalError("Unable to locate file \(resource).\(`extension`) in the bundle.") + } + do { + return try APITestData(data: Data(contentsOf: url)) + } catch { + fatalError("Unable to load data from \(resource).\(`extension`) in the bundle. Error: \(error)") + } + } +} + +extension APITestData { + // Create static instances to load API responses from the bundle or static data. + // Example: + // static let getSync = loadFromBundle(resource: "getSync", extension: "json") +} diff --git a/BitwardenShared/Core/Services/Platform/API/TestHelpers/MockHTTPClient.swift b/BitwardenShared/Core/Services/Platform/API/TestHelpers/MockHTTPClient.swift new file mode 100644 index 000000000..0ddf6bf9c --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/TestHelpers/MockHTTPClient.swift @@ -0,0 +1,45 @@ +import Networking + +/// An `HTTPClient` that can be used to return mocked responses. +/// +class MockHTTPClient: HTTPClient { + /// A list of requests that have been received by the HTTP client. + var requests: [HTTPRequest] = [] + + /// Gets the next result or sets a single result for the HTTP client to return for the next request. + var result: Result? { + get { + results.first + } + set { + guard let newValue else { + results.removeAll() + return + } + results = [newValue] + } + } + + /// A list of results that will be returned in order for future requests. + var results: [Result] = [] + + /// Sends a request and returns a mock response, if one exists. + /// + /// - Parameter request: The request to make on the client. + /// - Returns: A mock response for the request, if one exists. + /// + func send(_ request: HTTPRequest) async throws -> HTTPResponse { + requests.append(request) + + guard !results.isEmpty else { throw MockHTTPClientError.noResultForRequest } + + let result = results.removeFirst() + return try result.get() + } +} + +/// Errors thrown by `MockHTTPClient`. +enum MockHTTPClientError: Error { + /// There's no results set to + case noResultForRequest +} diff --git a/BitwardenShared/Core/Services/Platform/API/TestHelpers/Result+HTTPResponse.swift b/BitwardenShared/Core/Services/Platform/API/TestHelpers/Result+HTTPResponse.swift new file mode 100644 index 000000000..90bde30df --- /dev/null +++ b/BitwardenShared/Core/Services/Platform/API/TestHelpers/Result+HTTPResponse.swift @@ -0,0 +1,34 @@ +import Foundation +import Networking + +extension Result where Success == HTTPResponse, Error: Error { + static func httpSuccess(testData: APITestData) -> Result { + let response = HTTPResponse( + url: URL(string: "https://example.com")!, + statusCode: 200, + headers: [:], + body: testData.data, + requestID: UUID() + ) + return .success(response) + } + + static func httpFailure( + statusCode: Int = 500, + headers: [String: String] = [:], + data: Data = Data() + ) -> Result { + let response = HTTPResponse( + url: URL(string: "https://example.com")!, + statusCode: statusCode, + headers: headers, + body: data, + requestID: UUID() + ) + return .success(response) + } + + static func httpFailure(_ error: Error) -> Result { + .failure(error) + } +} diff --git a/BitwardenShared/Core/Services/Tools/API/SendAPIService.swift b/BitwardenShared/Core/Services/Tools/API/SendAPIService.swift new file mode 100644 index 000000000..f559e3c67 --- /dev/null +++ b/BitwardenShared/Core/Services/Tools/API/SendAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make send requests. +/// +protocol SendAPIService {} + +extension APIService: SendAPIService {} diff --git a/BitwardenShared/Core/Services/Vault/API/AttachmentAPIService.swift b/BitwardenShared/Core/Services/Vault/API/AttachmentAPIService.swift new file mode 100644 index 000000000..84d0e3fea --- /dev/null +++ b/BitwardenShared/Core/Services/Vault/API/AttachmentAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make attachment requests. +/// +protocol AttachmentAPIService {} + +extension APIService: AttachmentAPIService {} diff --git a/BitwardenShared/Core/Services/Vault/API/CipherAPIService.swift b/BitwardenShared/Core/Services/Vault/API/CipherAPIService.swift new file mode 100644 index 000000000..f8ce66a75 --- /dev/null +++ b/BitwardenShared/Core/Services/Vault/API/CipherAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make cipher requests. +/// +protocol CipherAPIService {} + +extension APIService: CipherAPIService {} diff --git a/BitwardenShared/Core/Services/Vault/API/FolderAPIService.swift b/BitwardenShared/Core/Services/Vault/API/FolderAPIService.swift new file mode 100644 index 000000000..7dfefd1bb --- /dev/null +++ b/BitwardenShared/Core/Services/Vault/API/FolderAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make folder requests. +/// +protocol FolderAPIService {} + +extension APIService: FolderAPIService {} diff --git a/BitwardenShared/Core/Services/Vault/API/SyncAPIService.swift b/BitwardenShared/Core/Services/Vault/API/SyncAPIService.swift new file mode 100644 index 000000000..eafa6cb00 --- /dev/null +++ b/BitwardenShared/Core/Services/Vault/API/SyncAPIService.swift @@ -0,0 +1,5 @@ +/// A protocol for an API service used to make sync requests. +/// +protocol SyncAPIService {} + +extension APIService: SyncAPIService {} From 2992d15ae245eece12b8b4a6fa39cade41a323cc Mon Sep 17 00:00:00 2001 From: Matt Czech Date: Tue, 29 Aug 2023 10:58:30 -0500 Subject: [PATCH 4/4] BIT-91: Fix warning --- .../Core/Services/Platform/API/APIServiceTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift b/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift index 35cfef965..7d73bc8cf 100644 --- a/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift +++ b/BitwardenShared/Core/Services/Platform/API/APIServiceTests.swift @@ -19,14 +19,14 @@ class APIServiceTests: BitwardenTestCase { } /// `init(client:)` sets the default base URLs for the HTTP services. - func testInitDefaultURLs() async { - let apiServiceBaseURL = await subject.apiService.baseURL + func testInitDefaultURLs() { + let apiServiceBaseURL = subject.apiService.baseURL XCTAssertEqual(apiServiceBaseURL, URL(string: "https://vault.bitwarden.com/api")!) - let eventsServiceBaseURL = await subject.eventsService.baseURL + let eventsServiceBaseURL = subject.eventsService.baseURL XCTAssertEqual(eventsServiceBaseURL, URL(string: "https://vault.bitwarden.com/events")!) - let identityServiceBaseURL = await subject.identityService.baseURL + let identityServiceBaseURL = subject.identityService.baseURL XCTAssertEqual(identityServiceBaseURL, URL(string: "https://vault.bitwarden.com/identity")!) } }