diff --git a/LICENSE b/LICENSE index 7e1db96..f2a0d74 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Viktor Gidlöf +Copyright (c) 2024 Viktor Gidlöf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Sources/Extensions/DataTaskPublisher.swift b/Sources/Extensions/DataTaskPublisher.swift deleted file mode 100644 index cc5a2b4..0000000 --- a/Sources/Extensions/DataTaskPublisher.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// DataTaskPublisher.swift -// Networking -// -// Created by Viktor Gidlöf. -// - -import Foundation -import Combine - -public extension URLSession.DataTaskPublisher { - /// Log the incoming response to the console - /// - parameter printJSON: A bool value that determines if the json respons is also printed to the console. Defaults to true. - /// - returns: The current publisher in the pipeline - func logResponse(printJSON: Bool = true) -> Publishers.HandleEvents<Self> { - handleEvents(receiveOutput: { value in - guard let httpResponse = value.response as? HTTPURLResponse, - let url = httpResponse.url?.absoluteString, - let comps = URLComponents(string: url), - let host = comps.host - else { return } - - Swift.print("♻️ Incoming response from \(host) @ \(Date())") - - let statusCode = httpResponse.statusCode - let statusCodeString = HTTPURLResponse.localizedString(forStatusCode: statusCode) - let path = comps.path - - var printOutput = "~ \(path)\n" - printOutput += "Status-Code: \(statusCode)\n" - printOutput += "Localized Status-Code: \(statusCodeString)\n" - - httpResponse.allHeaderFields.forEach { key, value in - if key.description == HTTP.Header.Field.contentLength || key.description == HTTP.Header.Field.contentType { - printOutput += "\(key): \(value)\n" - } - } - - Swift.print(printOutput) - if printJSON { - Swift.print("JSON response:") - Swift.print(value.data.prettyPrinted ?? "") - } - }) - } -} - -// MARK: - -private extension Data { - - /// Convert data into an optional pretty printed json string. - var prettyPrinted: String? { - guard let object = try? JSONSerialization.jsonObject(with: self, options: []), - let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]) - else { return nil } - return String(data: data, encoding: .utf8) - } -} diff --git a/Sources/Extensions/String.swift b/Sources/Extensions/String.swift index 37286d9..312f7bb 100644 --- a/Sources/Extensions/String.swift +++ b/Sources/Extensions/String.swift @@ -10,8 +10,8 @@ import Foundation public extension String { /// Create a URL from the string /// - returns: A new URL based on the given string value - func asURL() -> URL { - guard let url = URL(string: self) else { fatalError("The URL could not be created ❌ This should never happen!") } + func asURL() -> URL? { + guard let url = URL(string: self) else { return nil } return url } @@ -21,4 +21,45 @@ public extension String { func basicAuthentication(password: String) -> String { Data("\(self):\(password)".utf8).base64EncodedString() } + + static func logResponse(_ value: (data: Data, response: URLResponse), printJSON: Bool) { + guard let httpResponse = value.response as? HTTPURLResponse, + let url = httpResponse.url?.absoluteString, + let comps = URLComponents(string: url), + let host = comps.host + else { return } + + print("♻️ Incoming response from \(host) @ \(Date())") + + let statusCode = httpResponse.statusCode + let statusCodeString = HTTPURLResponse.localizedString(forStatusCode: statusCode) + let path = comps.path + + var printOutput = "~ \(path)\n" + printOutput += "Status-Code: \(statusCode)\n" + printOutput += "Localized Status-Code: \(statusCodeString)\n" + + httpResponse.allHeaderFields.forEach { key, value in + if key.description == HTTP.Header.Field.contentLength || key.description == HTTP.Header.Field.contentType { + printOutput += "\(key): \(value)\n" + } + } + + print(printOutput) + if printJSON { + print("JSON response:") + print(value.data.prettyPrinted ?? "") + } + } +} + +// MARK: - +private extension Data { + /// Convert data into an optional pretty printed json string. + var prettyPrinted: String? { + guard let object = try? JSONSerialization.jsonObject(with: self, options: []), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]) + else { return nil } + return String(data: data, encoding: .utf8) + } } diff --git a/Sources/Extensions/URLRequest.swift b/Sources/Extensions/URLRequest.swift index 676c7d9..1a0f7d1 100644 --- a/Sources/Extensions/URLRequest.swift +++ b/Sources/Extensions/URLRequest.swift @@ -34,7 +34,11 @@ public extension URLRequest { /// Print outgoing request information to the console func log() { - guard let url = url?.absoluteString, let components = URLComponents(string: url), let method = httpMethod, let host = components.host else { return } + guard let url = url?.absoluteString, + let components = URLComponents(string: url), + let method = httpMethod, + let host = components.host + else { return } print("⚡️ Outgoing request to \(host) @ \(Date())") diff --git a/Sources/ServerConfig/ServerConfig.swift b/Sources/ServerConfig/ServerConfig.swift index 2e754cf..439c750 100644 --- a/Sources/ServerConfig/ServerConfig.swift +++ b/Sources/ServerConfig/ServerConfig.swift @@ -2,56 +2,93 @@ // ServerConfig.swift // Networking // -// Created by Viktor Gidlöf. +// Created by VG on 2024-11-13. // import Foundation -/// An object for creating a server configuration for the backend API -open class ServerConfig { +public protocol ServerConfigurable { + var baseURL: URL { get } + func header(forRequest request: Requestable) -> HTTP.Header +} +// MARK: - +public struct ServerConfig: ServerConfigurable { // MARK: Private properties - private let tokenProvider: TokenProvidable? + private let additionalHeaders: HTTP.Header // MARK: - Public properties + public let userAgent: String? + /// A provider for authorization tokens used to authenticate requests; `nil` if no authentication is needed. + public let tokenProvider: TokenProvidable? /// The base URL for the server public let baseURL: URL - /// Init the server configuration - /// - parameters: - /// - baseURL: The given base URL used for this server config - /// - tokenProvider: An optional token provider object used to authenticate requests. Defaults to `nil`. - public init(baseURL: String, tokenProvider: TokenProvidable? = nil) { - self.baseURL = baseURL.asURL() + // MARK: - Initialization + /// Initializes a new instance of `ServerConfigV2` with the specified configuration details. + /// - Parameters: + /// - baseURL: A `String` representing the base URL for the server. This URL will be used as the primary endpoint for all requests. + /// - userAgent: An optional `String` representing the user agent to include in the request headers. If not provided, it defaults to a string combining `name` and `version`. + /// - additionalHeaders: An optional dictionary of additional headers to be merged into the default headers for each request. The default value is an empty dictionary. + /// - tokenProvider: An optional `TokenProvidable` object used to authenticate requests. This provider supplies authorization tokens when required by a request. Defaults to `nil`, meaning no token is provided. + /// - Returns: A configured instance of `ServerConfigV2` with the specified parameters. + public init?( + baseURL: String, + userAgent: String? = "\(name)/\(version)", + additionalHeaders: HTTP.Header = [:], + tokenProvider: TokenProvidable? = nil + ) { + guard let url = baseURL.asURL() else { return nil } + self.baseURL = url + self.userAgent = userAgent + self.additionalHeaders = additionalHeaders self.tokenProvider = tokenProvider } +} - /// Create a HTTP header for the requests. - /// Subclasses can call `super` if they need to implement the standard authentication. - /// Don't call `super` if you want to have a fully custom HTTP header implementation. - /// - parameter request: The given request to set up the header with - /// - returns: A new `HTTP.Header` dictionary - open func header(forRequest request: Requestable) -> HTTP.Header { - var header = HTTP.Header() - header[HTTP.Header.Field.userAgent] = "\(name)/\(version)" - header[HTTP.Header.Field.host] = baseURL.host - - if let contentType = request.contentType { - header[HTTP.Header.Field.contentType] = contentType - } +// MARK: - Public functions +public extension ServerConfig { + func header(forRequest request: Requestable) -> HTTP.Header { + var headers = HTTP.Header() + + // Base headers + if let host = baseURL.host { headers[HTTP.Header.Field.host] = host } + if let userAgent = userAgent { headers[HTTP.Header.Field.userAgent] = userAgent } + if let contentType = request.contentType { headers[HTTP.Header.Field.contentType] = contentType } - guard let tokenProvider = tokenProvider else { return header } + // Add any additional configured headers + headers.merge(additionalHeaders) { _, new in new } + + guard let tokenProvider else { return headers } switch tokenProvider.token { case .success(let token): - switch request.authorization { - case .bearer: header[HTTP.Header.Field.auth] = String(format: HTTP.Header.Field.bearer, token) - case .basic: header[HTTP.Header.Field.auth] = String(format: HTTP.Header.Field.basic, token) - case .none: break - } - case .failure: - break + guard let authHeader = authorizationHeader(for: request.authorization, token: token) else { break } + headers[HTTP.Header.Field.auth] = authHeader + case .failure: break + } + return headers + } +} + +// MARK: - Convenience Initializers +public extension ServerConfig { + static func basic(baseURL: String) -> ServerConfig? { + .init(baseURL: baseURL) + } + + static func authenticated(baseURL: String, tokenProvider: TokenProvidable) -> ServerConfig? { + .init(baseURL: baseURL, tokenProvider: tokenProvider) + } +} + +// MARK: - Private functions +private extension ServerConfig { + func authorizationHeader(for type: Request.Authorization, token: String) -> String? { + switch type { + case .bearer: return String(format: HTTP.Header.Field.bearer, token) + case .basic: return String(format: HTTP.Header.Field.basic, token) + case .none: return nil } - return header } } diff --git a/Sources/Service/DownloadPublisher.swift b/Sources/Service/DownloadPublisher.swift deleted file mode 100644 index 8e39d14..0000000 --- a/Sources/Service/DownloadPublisher.swift +++ /dev/null @@ -1,101 +0,0 @@ -// -// DownloadPublisher.swift -// Networking -// -// Created by Viktor Gidlöf. -// - -import Foundation -import Combine - -public extension Network.Service { - /// A downloader structure object used to track and progress file downloads - struct Downloader: Publisher { - public typealias Failure = Error - - // MARK: Private properties - private let url: URL - - // MARK: - Public properties - /// The publisher output - public enum Output { - /// The destination case containing the temporary file destination - case destination(URL) - /// The progress case containing the download progress as a `Float` value - case progress(Float) - } - - // MARK: - Init - /// Initialize the download publisher - /// - parameter url: The given file URL - init(url: URL) { - self.url = url - } - - // MARK: - Public functions - public func receive<S: Subscriber>(subscriber: S) where Output == S.Input, Failure == S.Failure { - let subscription = DownloadSubscription(subscriber, url: url) - subscriber.receive(subscription: subscription) - } - } -} - -// MARK: - -private extension Network.Service { - - /// A subscriber object that conforms to `URLSessionDownloadDelegate` used to report and track URL session downloads - private final class DownloadSubscription<S: Subscriber>: Network.Service.Sub<S>, URLSessionDownloadDelegate where S.Input == Downloader.Output, S.Failure == Error { - // MARK: Private properties - private var session: URLSession! - private let url: URL - - // MARK: - Init - /// Init the subscriber - /// - parameters: - /// - subscriber: The given subscriber - /// - url: The URL to the file to download - init(_ subscriber: S, url: URL) { - self.url = url - super.init(subscriber: subscriber) - - session = URLSession(configuration: .default, delegate: self, delegateQueue: .main) - session.downloadTask(with: url).resume() - _ = subscriber.receive(.progress(0.0)) - } - - // MARK: - URLSessionDownloadDelegate - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) - _ = subscriber?.receive(.progress(progress)) - } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { - _ = subscriber?.receive(.destination(location)) - subscriber?.receive(completion: .finished) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return } - subscriber?.receive(completion: .failure(error)) - } - } -} - -// MARK: - -private extension Network.Service { - /// A custom subscriber object used for creating subscriptions - class Sub<S: Subscriber>: NSObject, Subscription { - // MARK: Private properties - private(set) var subscriber: S? - - // MARK: - Init - init(subscriber: S) { - self.subscriber = subscriber - super.init() - } - - // MARK: - Public functions - func request(_ demand: Subscribers.Demand) {} - func cancel() { subscriber = nil } - } -} diff --git a/Sources/Service/Downloader.swift b/Sources/Service/Downloader.swift new file mode 100644 index 0000000..16cc1f6 --- /dev/null +++ b/Sources/Service/Downloader.swift @@ -0,0 +1,101 @@ +// +// Downloader.swift +// Networking +// +// Created by Viktor Gidlöf. +// + +import Foundation + +public extension Network.Service { + /// A downloader actor that handles file downloads with progress tracking + actor Downloader { + // MARK: Private properties + private let url: URL + private let session: URLSession + private var downloadTask: URLSessionDownloadTask? + private var progressContinuation: AsyncStream<Float>.Continuation? + + // MARK: - Init + init(url: URL, session: URLSession = .shared) { + self.url = url + self.session = session + } + } +} + +// MARK: - Public functions +public extension Network.Service.Downloader { + /// Start downloading the file and track progress + /// - Returns: A tuple containing the downloaded file URL and an AsyncStream of progress updates + func download() async throws -> (URL, AsyncStream<Float>) { + let (stream, continuation) = AsyncStream<Float>.makeStream() + progressContinuation = continuation + continuation.yield(0.0) + + let downloadedURL = try await withCheckedThrowingContinuation { continuation in + let delegate = DownloadDelegate { [weak self] result in + Task { [weak self] in + await self?.handleCompletion(result, continuation: continuation) + } + } progressHandler: { [weak self] progress in + Task { [weak self] in + await self?.handleProgress(progress) + } + } + let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let task = session.downloadTask(with: url) + downloadTask = task + task.resume() + } + return (downloadedURL, stream) + } + + /// Cancel the ongoing download + func cancel() { + downloadTask?.cancel() + progressContinuation?.finish() + } +} + +// MARK: - Private functions +private extension Network.Service.Downloader { + func handleCompletion(_ result: Result<URL, Error>, continuation: CheckedContinuation<URL, Error>) { + switch result { + case .success(let url): continuation.resume(returning: url) + case .failure(let error): continuation.resume(throwing: error) + } + progressContinuation?.finish() + } + + func handleProgress(_ progress: Float) { + progressContinuation?.yield(progress) + } +} + +// MARK: - Download Delegate +private final class DownloadDelegate: NSObject, URLSessionDownloadDelegate { + private let completionHandler: (Result<URL, Error>) -> Void + private let progressHandler: (Float) -> Void + + init(completionHandler: @escaping (Result<URL, Error>) -> Void, progressHandler: @escaping (Float) -> Void) { + self.completionHandler = completionHandler + self.progressHandler = progressHandler + super.init() + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + completionHandler(.success(location)) + } + + func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + let progress = Float(totalBytesWritten) / Float(totalBytesExpectedToWrite) + progressHandler(progress) + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + if let error = error { + completionHandler(.failure(error)) + } + } +} diff --git a/Sources/Service/NetworkService.swift b/Sources/Service/NetworkService.swift index 79caf30..f1a8cd0 100644 --- a/Sources/Service/NetworkService.swift +++ b/Sources/Service/NetworkService.swift @@ -9,7 +9,7 @@ import Foundation import Combine public let name = "Networking" -public let version = "0.8.9" +public let version = "0.9.0" public enum Network { /// A network service object used to make requests to the backend. @@ -30,65 +30,61 @@ public enum Network { self.decoder = decoder self.server = server } - - // MARK: - Private functions - private func dataTaskPublisher(_ request: Requestable, logResponse: Bool) throws -> AnyPublisher<URLSession.DataTaskPublisher.Output, Error> { - let config = Request.Config(request: request, server: server) - let urlRequest = try URLRequest(withConfig: config) - urlRequest.log() - - return session.dataTaskPublisher(for: urlRequest) - .logResponse(printJSON: logResponse) - .receive(on: RunLoop.main) - .tryMap { $0 } - .eraseToAnyPublisher() - } } } -// MARK: - +// MARK: - Public functions public extension Network.Service { - /// Create a new publisher that contains a decoded data model object + /// Send a request and decode the response into a data model object /// - parameters: /// - request: The request to send over the network /// - logResponse: A boolean value that determines if the json response should be printed to the console. Defaults to false. - /// - throws: An error if the data task publisher fails for any reason - /// - returns: A new publisher with the given data model object or an error - func request<DataModel: Decodable>(_ request: Requestable, logResponse: Bool = false) throws -> AnyPublisher<DataModel, Error> { - try dataPublisher(request, logResponse: logResponse) - .decode(type: DataModel.self, decoder: decoder) - .eraseToAnyPublisher() + /// - throws: An error if the request fails for any reason + /// - returns: The decoded data model object + func request<DataModel: Decodable>(_ request: Requestable, logResponse: Bool = false) async throws -> DataModel { + let (data, _) = try await makeDataRequest(request, logResponse: logResponse) + return try decoder.decode(DataModel.self, from: data) } - /// Create a new publisher that contains the response data + /// Send a request and return the raw response data /// - parameters: /// - request: The request to send over the network /// - logResponse: A boolean value that determines if the json response should be printed to the console. Defaults to false. - /// - throws: An error if the data task publisher fails for any reason - /// - returns: A new publisher with the data or an error - func dataPublisher(_ request: Requestable, logResponse: Bool = false) throws -> AnyPublisher<Data, Error> { - try dataTaskPublisher(request, logResponse: logResponse) - .map(\.data) - .eraseToAnyPublisher() + /// - throws: An error if the request fails for any reason + /// - returns: The raw response data + func data(_ request: Requestable, logResponse: Bool = false) async throws -> Data { + let (data, _) = try await makeDataRequest(request, logResponse: logResponse) + return data } - /// Create a new publisher that contains the HTTP status code + /// Send a request and return the HTTP status code /// - parameters: /// - request: The request to send over the network /// - logResponse: A boolean value that determines if the json response should be printed to the console. Defaults to false. - /// - throws: An error if the data task publisher fails for any reason - /// - returns: A new publisher with a bool value that determines if the request succeeded - func responsePublisher(_ request: Requestable, logResponse: Bool = false) throws -> AnyPublisher<HTTP.StatusCode, Error> { - try dataTaskPublisher(request, logResponse: logResponse) - .compactMap { $0.response as? HTTPURLResponse } - .map { HTTP.StatusCode(rawValue: $0.statusCode) ?? .unknown } - .eraseToAnyPublisher() + /// - throws: An error if the request fails for any reason + /// - returns: The HTTP status code + func response(_ request: Requestable, logResponse: Bool = false) async throws -> HTTP.StatusCode { + let (_, response) = try await makeDataRequest(request, logResponse: logResponse) + guard let httpResponse = response as? HTTPURLResponse else { return .unknown } + return HTTP.StatusCode(rawValue: httpResponse.statusCode) ?? .unknown } - /// Create a new publisher that publishes file download progress and the destination of the temporary file - /// - parameter url: The URL to the file to download - /// - returns: A new download publisher with the file download progress and destination URL - func downloadPublisher(url: URL) -> Network.Service.Downloader { + /// Creates a new instance of `Network.Service.Downloader` configured with the specified URL. + /// - Parameter url: The `URL` from which the downloader will retrieve data. + /// - Returns: A configured `Network.Service.Downloader` instance for downloading data from the given URL. + func downloader(url: URL) -> Network.Service.Downloader { Network.Service.Downloader(url: url) } } + +// MARK: - Private functions +private extension Network.Service { + func makeDataRequest(_ request: Requestable, logResponse: Bool) async throws -> (Data, URLResponse) { + let urlRequest = try request.configure(withServer: server) + let (data, response) = try await session.data(for: urlRequest) + if logResponse { + String.logResponse((data, response), printJSON: logResponse) + } + return (data, response) + } +} diff --git a/Tests/NetworkingTests/NetworkingTests.swift b/Tests/NetworkingTests/NetworkingTests.swift index 9349c8e..87c96e8 100644 --- a/Tests/NetworkingTests/NetworkingTests.swift +++ b/Tests/NetworkingTests/NetworkingTests.swift @@ -3,70 +3,38 @@ import Combine @testable import Networking final class NetworkingTests: XCTestCase { - - private let serverConfig = ServerConfig(baseURL: "https://reqres.in/api") + private let serverConfig = ServerConfig(baseURL: "https://reqres.in/api")! private lazy var networkService = Network.Service(server: serverConfig) - private var cancel: AnyCancellable? - func testMockUser() throws { + func testMockUser() async throws { let user = MockGetRequest.user(1) - let expectation = expectation(description: "Awaiting mock user") - - cancel = try networkService.request(user).sink { (result: Result<MockUserResponse, Error>) in - expectation.fulfill() - switch result { - case .success(let user): XCTAssertTrue(user.data.id == 1) - case .failure(let error): XCTFail(error.localizedDescription) - } - } - waitForExpectations(timeout: 30.0) + let response: MockUserResponse = try await networkService.request(user) + XCTAssertTrue(response.data.id == 1) } - func testMockUsers() throws { + func testMockUsers() async throws { let users = MockGetRequest.users - let expectation = expectation(description: "Awaiting mock users") - - cancel = try networkService.request(users).sink { (result: Result<MockUsersRepsonse, Error>) in - expectation.fulfill() - switch result { - case .success(let user): XCTAssertTrue(!user.data.isEmpty) - case .failure(let error): XCTFail(error.localizedDescription) - } - } - waitForExpectations(timeout: 30.0) + let response: MockUsersRepsonse = try await networkService.request(users) + XCTAssertTrue(!response.data.isEmpty) } - func testMockPostUser() throws { + func testMockPostUser() async throws { let model = MockPostUserModel(name: "Viktor", job: "iOS Engineer") let users = MockPostRequest.user(model) - let expectation = expectation(description: "Awaiting mock post users") - - cancel = try networkService.responsePublisher(users).sink { result in - expectation.fulfill() - switch result { - case .success(let responseCode): XCTAssertTrue(responseCode == .created) - case .failure(let error): XCTFail(error.localizedDescription) - } - } - waitForExpectations(timeout: 30.0) + let responseCode = try await networkService.response(users) + XCTAssertTrue(responseCode == .created) } - func testDownloadImageFile() { + func testDownloadImageFile() async throws { let url = "https://media.viktorgidlof.com/2022/12/djunglehorse.jpg".asURL() - let expectation = expectation(description: "Awaiting image download progress") + let downloader = Network.Service.Downloader(url: url!) + let (fileURL, progress) = try await downloader.download() - cancel = networkService.downloadPublisher(url: url).sink { result in - switch result { - case .success(.destination(let url)): - expectation.fulfill() - XCTAssertTrue(url.lastPathComponent.split(separator: ".").last == "tmp") - case .success(.progress(let progress)): - print("Download progress: \(progress)") - case .failure(let error): - expectation.fulfill() - XCTFail(error.localizedDescription) + Task { + for await progressValue in progress { + print("Download progress: \(progressValue)") } } - waitForExpectations(timeout: 30.0) + XCTAssertTrue(fileURL.lastPathComponent.split(separator: ".").last == "tmp") } } diff --git a/Tests/NetworkingTests/ServerConfigTests.swift b/Tests/NetworkingTests/ServerConfigTests.swift new file mode 100644 index 0000000..d9212d9 --- /dev/null +++ b/Tests/NetworkingTests/ServerConfigTests.swift @@ -0,0 +1,108 @@ +// +// ServerConfigTests.swift +// Networking +// +// Created by VG on 2024-11-13. +// + +import XCTest +@testable import Networking + +final class ServerConfigV2Tests: XCTestCase { + private let validURLString = "https://api.example.com" + private let tokenProviderMock = MockTokenProvider() + private var config: ServerConfig! + + override func setUp() { + super.setUp() + config = ServerConfig(baseURL: validURLString, tokenProvider: tokenProviderMock) + } + + override func tearDown() { + config = nil + super.tearDown() + } + + // MARK: - Initialization Tests + func testInitialization_withValidURL_shouldSucceed() { + let config = ServerConfig(baseURL: validURLString, userAgent: "TestAgent/1.0") + XCTAssertEqual(config?.baseURL.absoluteString, validURLString) + XCTAssertEqual(config?.userAgent, "TestAgent/1.0") + } + + func testInitialization_withInvalidURL_shouldReturnNil() { + let config = ServerConfig(baseURL: "") // <-- Invalid URL string + XCTAssertNil(config) + } + + // MARK: - Header Generation Tests + func testHeaderGeneration_includesBaseHeaders() { + let request = MockRequestable(authorization: .none) + let headers = config.header(forRequest: request) + XCTAssertEqual(headers[HTTP.Header.Field.host], config.baseURL.host) + } + + func testHeaderGeneration_includesAdditionalHeaders() { + let additionalHeaders = ["X-Custom-Header": "CustomValue"] + let config = ServerConfig(baseURL: validURLString, additionalHeaders: additionalHeaders) + let headers = config?.header(forRequest: MockRequestable(authorization: .none)) + XCTAssertEqual(headers?["X-Custom-Header"], "CustomValue") + } + + func testHeaderGeneration_withBearerToken_includesAuthorizationHeader() { + tokenProviderMock.tokenResult = .success("sampleToken") + let request = MockRequestable(authorization: .bearer) + let headers = config.header(forRequest: request) + XCTAssertEqual(headers[HTTP.Header.Field.auth], "Bearer sampleToken") + } + + func testHeaderGeneration_withBasicToken_includesAuthorizationHeader() { + tokenProviderMock.tokenResult = .success("sampleToken") + let request = MockRequestable(authorization: .basic) + let headers = config.header(forRequest: request) + XCTAssertEqual(headers[HTTP.Header.Field.auth], "Basic sampleToken") + } + + func testHeaderGeneration_withTokenProviderFailure_excludesAuthorizationHeader() { + tokenProviderMock.tokenResult = .failure(.missing) + let request = MockRequestable(authorization: .bearer) + let headers = config.header(forRequest: request) + XCTAssertNil(headers[HTTP.Header.Field.auth]) + } + + // MARK: - Convenience Initializers Tests + func testBasicInitializer_createsConfigurationWithDefaultValues() { + let config = ServerConfig.basic(baseURL: validURLString) + XCTAssertEqual(config?.baseURL.absoluteString, validURLString) + XCTAssertNil(config?.tokenProvider) + } + + func testAuthenticatedInitializer_createsConfigurationWithTokenProvider() { + let config = ServerConfig.authenticated(baseURL: validURLString, tokenProvider: tokenProviderMock) + XCTAssertEqual(config?.baseURL.absoluteString, validURLString) + XCTAssertNotNil(config?.tokenProvider) + } +} + +// MARK: - Mocks + +private class MockTokenProvider: TokenProvidable { + var tokenResult: Result<String, TokenProvidableError> = .failure(.missing) + var token: Result<String, TokenProvidableError> { tokenResult } + + func setToken(_ token: String) {} + func reset() {} +} + +private struct MockRequestable: Requestable { + var encoding: Request.Encoding = .query + var httpMethod: HTTP.Method = .get + var endpoint: EndpointType = Endpoint.endpoint + var authorization: Request.Authorization + var contentType: String? +} + +private enum Endpoint: EndpointType { + case endpoint + var path: String { "path" } +}