diff --git a/.swiftlint.yml b/.swiftlint.yml index f87a47e..467ebdb 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -39,11 +39,10 @@ implicitly_unwrapped_optional: error force_cast: warning force_try: warning -# 가이드 정합: 한 줄은 짧게 유지 -# StyleShare/일반 가이드의 99에 가깝게 초안 설정 +# 한 줄 길이: 초안 단계 노이즈 완화를 위해 상향 line_length: - warning: 100 - error: 120 + warning: 120 + error: 140 ignores_comments: true ignores_urls: true @@ -59,15 +58,19 @@ file_length: warning: 400 error: 600 +# 테스트 코드의 짧은 타입명(OK 등) 허용을 위해 최소 길이 완화 type_name: - min_length: 3 + min_length: + warning: 2 + error: 1 max_length: warning: 50 error: 60 +# 테스트 코드의 짧은 식별자(a/b/c 등) 허용을 위해 최소 길이 완화 identifier_name: min_length: - warning: 2 + warning: 1 error: 1 max_length: warning: 50 @@ -99,6 +102,7 @@ excluded: - .build - Derived - DerivedData + - .derivedData - .git - Mozi.xcworkspace - "**/*.xcodeproj" diff --git a/Projects/Core/Network/Project.swift b/Projects/Core/Network/Project.swift index a4aeba6..7d2ff53 100644 --- a/Projects/Core/Network/Project.swift +++ b/Projects/Core/Network/Project.swift @@ -5,5 +5,7 @@ let project = ProjectFactory.framework( .coreNetwork, dependencies: [ .sharedUtils, - ] + .sharedLogger, + ], + includesTests: true ) diff --git a/Projects/Core/Network/Sources/Auth/TokenProviding.swift b/Projects/Core/Network/Sources/Auth/TokenProviding.swift new file mode 100644 index 0000000..b669a65 --- /dev/null +++ b/Projects/Core/Network/Sources/Auth/TokenProviding.swift @@ -0,0 +1,3 @@ +public protocol TokenProviding: Sendable { + func accessToken() async throws -> String? +} diff --git a/Projects/Core/Network/Sources/Auth/TokenRefreshing.swift b/Projects/Core/Network/Sources/Auth/TokenRefreshing.swift new file mode 100644 index 0000000..d3ff2cd --- /dev/null +++ b/Projects/Core/Network/Sources/Auth/TokenRefreshing.swift @@ -0,0 +1,3 @@ +public protocol TokenRefreshing: Sendable { + func refresh() async throws +} diff --git a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift new file mode 100644 index 0000000..3a584f4 --- /dev/null +++ b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift @@ -0,0 +1,227 @@ +import Foundation + +public actor DefaultNetworkClient: NetworkClient { + private let configuration: NetworkConfiguration + private let session: URLSession + private let tokenProvider: (any TokenProviding)? + private let tokenRefresher: (any TokenRefreshing)? + private var refreshTask: Task? + private var accessTokenGeneration = 0 + + private init( + configuration: NetworkConfiguration, + session: URLSession, + tokenProvider: (any TokenProviding)?, + tokenRefresher: (any TokenRefreshing)? + ) { + self.configuration = configuration + self.session = session + self.tokenProvider = tokenProvider + self.tokenRefresher = tokenRefresher + } + + public static func plain( + configuration: NetworkConfiguration, + session: URLSession = .shared + ) -> DefaultNetworkClient { + DefaultNetworkClient( + configuration: configuration, + session: session, + tokenProvider: nil, + tokenRefresher: nil + ) + } + + public static func authed( + configuration: NetworkConfiguration, + tokenProvider: any TokenProviding, + tokenRefresher: any TokenRefreshing, + session: URLSession = .shared + ) -> DefaultNetworkClient { + DefaultNetworkClient( + configuration: configuration, + session: session, + tokenProvider: tokenProvider, + tokenRefresher: tokenRefresher + ) + } + + public func request(_ endpoint: some APIEndpoint) async throws -> T { + let data = try await perform(endpoint) + do { + return try configuration.jsonDecoder.decode(T.self, from: data) + } catch { + NetworkLog.error(error, url: nil) + throw NetworkError.decodingFailed + } + } + + public func request(_ endpoint: some APIEndpoint) async throws { + _ = try await perform(endpoint) + } + + private func perform(_ endpoint: some APIEndpoint) async throws -> Data { + let (_, data) = try await send(endpoint, allowRefresh: true) + return data + } + + private func send( + _ endpoint: some APIEndpoint, + allowRefresh: Bool + ) async throws -> (HTTPURLResponse, Data) { + let prepared = try await makeURLRequest(for: endpoint) + NetworkLog.request(prepared.request) + + let started = Date() + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: prepared.request) + } catch { + NetworkLog.error(error, url: prepared.request.url) + throw NetworkError.transport(message: error.localizedDescription) + } + + let durationMs = Int(Date().timeIntervalSince(started) * 1000) + guard let httpResponse = response as? HTTPURLResponse else { + let error = NetworkError.invalidResponse + NetworkLog.error(error, url: prepared.request.url) + throw error + } + + NetworkLog.response( + statusCode: httpResponse.statusCode, + url: prepared.request.url, + data: data, + durationMs: durationMs + ) + + if httpResponse.statusCode == 401 { + guard tokenRefresher != nil, allowRefresh else { + throw NetworkError.unauthorized + } + + // 이미 다른 요청이 refresh를 끝낸 뒤라면 중복 refresh 없이 재시도한다. + if let usedGeneration = prepared.accessTokenGeneration, + usedGeneration < accessTokenGeneration { + return try await send(endpoint, allowRefresh: false) + } + + try await refreshSingleFlight() + return try await send(endpoint, allowRefresh: false) + } + + guard (200...299).contains(httpResponse.statusCode) else { + throw mapStatusCode(httpResponse.statusCode, data: data) + } + + return (httpResponse, data) + } + + private func refreshSingleFlight() async throws { + if let refreshTask { + try await refreshTask.value + return + } + + let task = Task { + guard let tokenRefresher else { + throw NetworkError.unauthorized + } + do { + try await tokenRefresher.refresh() + } catch { + throw NetworkError.unauthorized + } + } + refreshTask = task + + do { + try await task.value + accessTokenGeneration += 1 + refreshTask = nil + } catch { + refreshTask = nil + throw error + } + } + + private func makeURLRequest(for endpoint: some APIEndpoint) async throws -> PreparedRequest { + guard var components = URLComponents( + url: configuration.baseURL.appendingPathComponent(normalizedPath(endpoint.path)), + resolvingAgainstBaseURL: false + ) else { + throw NetworkError.invalidURL + } + + if !endpoint.queryItems.isEmpty { + components.queryItems = endpoint.queryItems + } + + guard let url = components.url else { + throw NetworkError.invalidURL + } + + var request = URLRequest(url: url, timeoutInterval: configuration.timeout) + request.httpMethod = endpoint.method.rawValue + request.httpBody = endpoint.body + + request.setValue("application/json", forHTTPHeaderField: "Accept") + if endpoint.body != nil { + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + for (key, value) in endpoint.headers { + request.setValue(value, forHTTPHeaderField: key) + } + + var usedGeneration: Int? + if let tokenProvider { + if let token = try await tokenProvider.accessToken() { + guard url.scheme?.lowercased() == "https" else { + throw NetworkError.invalidURL + } + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + usedGeneration = accessTokenGeneration + } + } + + return PreparedRequest(request: request, accessTokenGeneration: usedGeneration) + } + + private func normalizedPath(_ path: String) -> String { + if path.hasPrefix("/") { + return String(path.dropFirst()) + } + return path + } + + private func mapStatusCode(_ statusCode: Int, data: Data) -> NetworkError { + let message = try? configuration.jsonDecoder + .decode(ErrorMessageDTO.self, from: data) + .message + switch statusCode { + case 400: + return .badRequest(message: message) + case 401: + return .unauthorized + case 403: + return .forbidden(message: message) + case 404: + return .notFound(message: message) + case 409: + return .conflict(message: message) + case 400...499: + return .clientError(statusCode: statusCode, message: message) + case 500...599: + return .serverError(statusCode: statusCode, message: message) + default: + return .serverError(statusCode: statusCode, message: message) + } + } +} + +private struct PreparedRequest: Sendable { + let request: URLRequest + let accessTokenGeneration: Int? +} diff --git a/Projects/Core/Network/Sources/Client/NetworkClient.swift b/Projects/Core/Network/Sources/Client/NetworkClient.swift new file mode 100644 index 0000000..5b9f487 --- /dev/null +++ b/Projects/Core/Network/Sources/Client/NetworkClient.swift @@ -0,0 +1,6 @@ +import Foundation + +public protocol NetworkClient: Sendable { + func request(_ endpoint: some APIEndpoint) async throws -> T + func request(_ endpoint: some APIEndpoint) async throws +} diff --git a/Projects/Core/Network/Sources/Config/NetworkConfiguration.swift b/Projects/Core/Network/Sources/Config/NetworkConfiguration.swift new file mode 100644 index 0000000..29bb756 --- /dev/null +++ b/Projects/Core/Network/Sources/Config/NetworkConfiguration.swift @@ -0,0 +1,20 @@ +import Foundation + +public struct NetworkConfiguration: Sendable { + public let baseURL: URL + public let timeout: TimeInterval + public let jsonDecoder: JSONDecoder + public let jsonEncoder: JSONEncoder + + public init( + baseURL: URL, + timeout: TimeInterval = 30, + jsonDecoder: JSONDecoder = NetworkJSONCoding.makeDecoder(), + jsonEncoder: JSONEncoder = NetworkJSONCoding.makeEncoder() + ) { + self.baseURL = baseURL + self.timeout = timeout + self.jsonDecoder = jsonDecoder + self.jsonEncoder = jsonEncoder + } +} diff --git a/Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift b/Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift new file mode 100644 index 0000000..d2041b9 --- /dev/null +++ b/Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift @@ -0,0 +1,47 @@ +import Foundation + +public enum NetworkJSONCoding { + public static func makeDecoder() -> JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let value = try container.decode(String.self) + + if let date = NetworkJSONCoding.dateTimeFormatter.date(from: value) { + return date + } + if let date = NetworkJSONCoding.dateFormatter.date(from: value) { + return date + } + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unrecognized date format: \(value)" + ) + } + return decoder + } + + public static func makeEncoder() -> JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(dateTimeFormatter) + return encoder + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + + private static let dateTimeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + return formatter + }() +} diff --git a/Projects/Core/Network/Sources/Endpoint/APIEndpoint.swift b/Projects/Core/Network/Sources/Endpoint/APIEndpoint.swift new file mode 100644 index 0000000..0d63fcb --- /dev/null +++ b/Projects/Core/Network/Sources/Endpoint/APIEndpoint.swift @@ -0,0 +1,15 @@ +import Foundation + +public protocol APIEndpoint: Sendable { + var path: String { get } + var method: HTTPMethod { get } + var headers: [String: String] { get } + var queryItems: [URLQueryItem] { get } + var body: Data? { get } +} + +public extension APIEndpoint { + var headers: [String: String] { [:] } + var queryItems: [URLQueryItem] { [] } + var body: Data? { nil } +} diff --git a/Projects/Core/Network/Sources/Endpoint/HTTPMethod.swift b/Projects/Core/Network/Sources/Endpoint/HTTPMethod.swift new file mode 100644 index 0000000..8b1810e --- /dev/null +++ b/Projects/Core/Network/Sources/Endpoint/HTTPMethod.swift @@ -0,0 +1,7 @@ +public enum HTTPMethod: String, Sendable { + case get = "GET" + case post = "POST" + case put = "PUT" + case patch = "PATCH" + case delete = "DELETE" +} diff --git a/Projects/Core/Network/Sources/Error/ErrorMessageDTO.swift b/Projects/Core/Network/Sources/Error/ErrorMessageDTO.swift new file mode 100644 index 0000000..fe15e34 --- /dev/null +++ b/Projects/Core/Network/Sources/Error/ErrorMessageDTO.swift @@ -0,0 +1,3 @@ +struct ErrorMessageDTO: Decodable, Sendable { + let message: String +} diff --git a/Projects/Core/Network/Sources/Error/NetworkError.swift b/Projects/Core/Network/Sources/Error/NetworkError.swift new file mode 100644 index 0000000..3c65df0 --- /dev/null +++ b/Projects/Core/Network/Sources/Error/NetworkError.swift @@ -0,0 +1,15 @@ +import Foundation + +public enum NetworkError: Error, Equatable, Sendable { + case invalidURL + case invalidResponse + case decodingFailed + case badRequest(message: String?) + case unauthorized + case forbidden(message: String?) + case notFound(message: String?) + case conflict(message: String?) + case clientError(statusCode: Int, message: String?) + case serverError(statusCode: Int, message: String?) + case transport(message: String) +} diff --git a/Projects/Core/Network/Sources/Logging/NetworkLog.swift b/Projects/Core/Network/Sources/Logging/NetworkLog.swift new file mode 100644 index 0000000..aef3675 --- /dev/null +++ b/Projects/Core/Network/Sources/Logging/NetworkLog.swift @@ -0,0 +1,75 @@ +import Foundation +import SharedLogger + +enum NetworkLog { + static func request(_ request: URLRequest) { + let method = request.httpMethod ?? "NIL" + let url = sanitizedURLString(request.url) + var message = "→ \(method) \(url)" + + #if DEBUG + if let body = request.httpBody, let bodyText = String(data: body, encoding: .utf8) { + message += "\nBody: \(redact(bodyText))" + } + #endif + + Logger.shared.info(redact(message), category: .network) + } + + static func response( + statusCode: Int, + url: URL?, + data: Data, + durationMs: Int + ) { + let target = sanitizedURLString(url) + var message = "← \(statusCode) \(target) (\(durationMs)ms, \(data.count)B)" + + #if DEBUG + if let bodyText = String(data: data, encoding: .utf8), !bodyText.isEmpty { + message += "\nBody: \(redact(bodyText))" + } + #endif + + if (200...299).contains(statusCode) { + Logger.shared.info(message, category: .network) + } else { + Logger.shared.warning(message, category: .network) + } + } + + static func error(_ error: Error, url: URL?) { + let target = sanitizedURLString(url) + Logger.shared.error("✕ \(target) \(error.localizedDescription)", category: .network) + } + + static func sanitizedURLString(_ url: URL?) -> String { + guard let url else { return "nil" } + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.query = nil + components.fragment = nil + return components.string ?? url.absoluteString + } + + static func redact(_ text: String) -> String { + var output = text + let patterns = [ + #"Bearer\s+[A-Za-z0-9\-._~+/]+=*"#, + #"(accessToken|refreshToken|Authorization)"\s*:\s*"[^"]+""#, + ] + for pattern in patterns { + if let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) { + let range = NSRange(output.startIndex.. Int { + lock.lock() + defer { lock.unlock() } + count += 1 + return count + } + } + + let counter = RequestCounter() + URLProtocolStub.requestHandler = { _ in + let index = counter.next() + if index == 1 { + return .init( + statusCode: 401, + headers: [:], + data: Data(#"{"message":"expired"}"#.utf8) + ) + } + if index == 2 { + // late 401 for a request that started with the old token generation + Thread.sleep(forTimeInterval: 0.25) + return .init( + statusCode: 401, + headers: [:], + data: Data(#"{"message":"expired"}"#.utf8) + ) + } + return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) + } + + struct OkPayload: Decodable { let ok: Bool } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher(provider: provider, nextToken: "access-token-2") + await refresher.setDelayNanoseconds(50_000_000) + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: provider, + tokenRefresher: refresher, + session: TestSessionFactory.make() + ) + + async let first: OkPayload = client.request(TestEndpoint(path: "/first")) + try await Task.sleep(nanoseconds: 20_000_000) + async let second: OkPayload = client.request(TestEndpoint(path: "/second")) + _ = try await (first, second) + + let refreshCount = await refresher.refreshCount + XCTAssertEqual(refreshCount, 1) + } + + private struct Dummy: Decodable { let value: Int } +} diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift new file mode 100644 index 0000000..f5ce18b --- /dev/null +++ b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift @@ -0,0 +1,246 @@ +import CoreNetwork +import XCTest + +final class DefaultNetworkClientTests: XCTestCase { + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func test_request_buildsMethodPathQueryHeadersAndBody() async throws { + URLProtocolStub.requestHandler = { request in + XCTAssertEqual(request.httpMethod, "POST") + XCTAssertEqual(request.url?.path, "/api/auth/login/kakao") + XCTAssertEqual(request.url?.query, "debug=1") + XCTAssertEqual(request.value(forHTTPHeaderField: "Accept"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/json") + XCTAssertEqual(request.value(forHTTPHeaderField: "X-Test"), "1") + XCTAssertEqual(request.httpBody, Data(#"{"accessToken":"k"}"#.utf8)) + return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) + } + + struct OkPayload: Decodable, Equatable { let ok: Bool } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let client = DefaultNetworkClient.plain( + configuration: NetworkConfiguration(baseURL: baseURL), + session: TestSessionFactory.make() + ) + + let endpoint = TestEndpoint( + path: "/api/auth/login/kakao", + method: .post, + headers: ["X-Test": "1", "Content-Type": "application/json"], + queryItems: [.init(name: "debug", value: "1")], + body: Data(#"{"accessToken":"k"}"#.utf8) + ) + + let response: OkPayload = try await client.request(endpoint) + XCTAssertEqual(response, OkPayload(ok: true)) + } + + func test_voidRequest_succeedsOn2xxAndIgnoresBody() async throws { + URLProtocolStub.requestHandler = { _ in + .init(statusCode: 204, headers: [:], data: Data(#"{"ignored":true}"#.utf8)) + } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let client = DefaultNetworkClient.plain( + configuration: NetworkConfiguration(baseURL: baseURL), + session: TestSessionFactory.make() + ) + try await client.request(TestEndpoint(path: "/api/auth/logout", method: .post)) + } + + func test_requestDecodable_emptyBody_throwsDecodingFailed() async { + URLProtocolStub.requestHandler = { _ in + .init(statusCode: 204, headers: [:], data: Data()) + } + struct Dummy: Decodable { let value: Int } + guard let baseURL = URL(string: "https://api.example.invalid") else { + XCTFail("invalid base URL") + return + } + let client = DefaultNetworkClient.plain( + configuration: NetworkConfiguration(baseURL: baseURL), + session: TestSessionFactory.make() + ) + do { + let _: Dummy = try await client.request(TestEndpoint()) + XCTFail("expected decodingFailed") + } catch let error as NetworkError { + XCTAssertEqual(error, .decodingFailed) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_authedRequest_attachesBearerToken() async throws { + URLProtocolStub.requestHandler = { request in + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer access-token" + ) + return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) + } + + struct OkPayload: Decodable { let ok: Bool } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher() + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: provider, + tokenRefresher: refresher, + session: TestSessionFactory.make() + ) + + let _: OkPayload = try await client.request(TestEndpoint()) + } + + func test_authed401_refreshesOnceAndRetries() async throws { + final class CodeQueue: @unchecked Sendable { + private let lock = NSLock() + private var codes = [401, 200] + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + return codes.removeFirst() + } + } + + let queue = CodeQueue() + URLProtocolStub.requestHandler = { _ in + let code = queue.next() + if code == 200 { + return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) + } + return .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) + } + + struct OkPayload: Decodable, Equatable { let ok: Bool } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher() + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: provider, + tokenRefresher: refresher, + session: TestSessionFactory.make() + ) + + let value: OkPayload = try await client.request(TestEndpoint()) + XCTAssertEqual(value.ok, true) + let refreshCount = await refresher.refreshCount + XCTAssertEqual(refreshCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 2) + } + + func test_concurrent401_singleFlightRefresh() async throws { + final class RequestCounter: @unchecked Sendable { + private let lock = NSLock() + private var requestIndex = 0 + + func next() -> Int { + lock.lock() + defer { lock.unlock() } + requestIndex += 1 + return requestIndex + } + } + + let counter = RequestCounter() + URLProtocolStub.requestHandler = { _ in + let requestIndex = counter.next() + // first 3 are original 401s, next 3 are retries 200 + if requestIndex <= 3 { + return .init( + statusCode: 401, + headers: [:], + data: Data(#"{"message":"expired"}"#.utf8) + ) + } + return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) + } + + struct OkPayload: Decodable { let ok: Bool } + let baseURL = try XCTUnwrap(URL(string: "https://api.example.invalid")) + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher() + // Keep refresh in-flight long enough for concurrent 401s to join single-flight. + await refresher.setDelayNanoseconds(200_000_000) + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: provider, + tokenRefresher: refresher, + session: TestSessionFactory.make() + ) + + async let first: OkPayload = client.request(TestEndpoint(path: "/a")) + async let second: OkPayload = client.request(TestEndpoint(path: "/b")) + async let third: OkPayload = client.request(TestEndpoint(path: "/c")) + _ = try await (first, second, third) + + let refreshCount = await refresher.refreshCount + XCTAssertEqual(refreshCount, 1) + XCTAssertEqual(URLProtocolStub.requests.count, 6) + } + + func test_refreshFailure_mapsToUnauthorized() async { + URLProtocolStub.requestHandler = { _ in + .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) + } + + guard let baseURL = URL(string: "https://api.example.invalid") else { + XCTFail("invalid base URL") + return + } + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher() + await refresher.setError( + NetworkError.serverError(statusCode: 500, message: "refresh failed") + ) + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: provider, + tokenRefresher: refresher, + session: TestSessionFactory.make() + ) + + do { + let _: Dummy = try await client.request(TestEndpoint()) + XCTFail("expected unauthorized") + } catch let error as NetworkError { + XCTAssertEqual(error, .unauthorized) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_retryStill401_mapsToUnauthorized() async { + URLProtocolStub.requestHandler = { _ in + .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) + } + + guard let baseURL = URL(string: "https://api.example.invalid") else { + XCTFail("invalid base URL") + return + } + let client = DefaultNetworkClient.authed( + configuration: NetworkConfiguration(baseURL: baseURL), + tokenProvider: StubTokenProvider(token: "access-token"), + tokenRefresher: StubTokenRefresher(), + session: TestSessionFactory.make() + ) + + do { + let _: Dummy = try await client.request(TestEndpoint()) + XCTFail("expected unauthorized") + } catch let error as NetworkError { + XCTAssertEqual(error, .unauthorized) + } catch { + XCTFail("unexpected \(error)") + } + } + + private struct Dummy: Decodable { let value: Int } +} diff --git a/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift new file mode 100644 index 0000000..6267048 --- /dev/null +++ b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift @@ -0,0 +1,65 @@ +import CoreNetwork +import XCTest + +final class NetworkErrorMappingTests: XCTestCase { + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func test_statusCodeMapping() async { + let cases: [(Int, NetworkError)] = [ + (400, .badRequest(message: "bad")), + (401, .unauthorized), + (403, .forbidden(message: "no")), + (404, .notFound(message: "missing")), + (409, .conflict(message: "dup")), + (422, .clientError(statusCode: 422, message: "invalid")), + (429, .clientError(statusCode: 429, message: "slow")), + (500, .serverError(statusCode: 500, message: "boom")), + ] + + for (status, expected) in cases { + URLProtocolStub.reset() + let message = expectedMessage(for: expected) + URLProtocolStub.requestHandler = { _ in + let data = Data(#"{"message":"\#(message)"}"#.utf8) + return .init(statusCode: status, headers: [:], data: data) + } + guard let baseURL = URL(string: "https://api.example.invalid") else { + XCTFail("invalid base URL") + return + } + let client = DefaultNetworkClient.plain( + configuration: NetworkConfiguration(baseURL: baseURL), + session: TestSessionFactory.make() + ) + do { + let _: Dummy = try await client.request(TestEndpoint()) + XCTFail("status \(status) should fail") + } catch let error as NetworkError { + XCTAssertEqual(error, expected, "status \(status)") + } catch { + XCTFail("status \(status) unexpected \(error)") + } + } + } + + private struct Dummy: Decodable { let value: Int } + + private func expectedMessage(for error: NetworkError) -> String { + switch error { + case let .badRequest(message), + let .forbidden(message), + let .notFound(message), + let .conflict(message), + let .clientError(_, message), + let .serverError(_, message): + return message ?? "" + case .unauthorized: + return "unauthorized" + default: + return "" + } + } +} diff --git a/Projects/Core/Network/Tests/NetworkLogTests.swift b/Projects/Core/Network/Tests/NetworkLogTests.swift new file mode 100644 index 0000000..823920d --- /dev/null +++ b/Projects/Core/Network/Tests/NetworkLogTests.swift @@ -0,0 +1,15 @@ +@testable import CoreNetwork +import XCTest + +final class NetworkLogTests: XCTestCase { + func test_sanitizedURLString_removesQueryAndFragment() throws { + let url = try XCTUnwrap( + URL(string: "https://api.example.invalid/auth/callback?accessToken=secret&page=1#top") + ) + let sanitized = NetworkLog.sanitizedURLString(url) + XCTAssertEqual(sanitized, "https://api.example.invalid/auth/callback") + XCTAssertFalse(sanitized.contains("accessToken=secret")) + XCTAssertFalse(sanitized.contains("page=1")) + XCTAssertFalse(sanitized.contains("#top")) + } +} diff --git a/Projects/Core/Network/Tests/Support/TestEndpoint.swift b/Projects/Core/Network/Tests/Support/TestEndpoint.swift new file mode 100644 index 0000000..e3876e6 --- /dev/null +++ b/Projects/Core/Network/Tests/Support/TestEndpoint.swift @@ -0,0 +1,24 @@ +import CoreNetwork +import Foundation + +struct TestEndpoint: APIEndpoint { + var path: String + var method: HTTPMethod + var headers: [String: String] + var queryItems: [URLQueryItem] + var body: Data? + + init( + path: String = "/api/users/me", + method: HTTPMethod = .get, + headers: [String: String] = [:], + queryItems: [URLQueryItem] = [], + body: Data? = nil + ) { + self.path = path + self.method = method + self.headers = headers + self.queryItems = queryItems + self.body = body + } +} diff --git a/Projects/Core/Network/Tests/Support/TokenStubs.swift b/Projects/Core/Network/Tests/Support/TokenStubs.swift new file mode 100644 index 0000000..91add89 --- /dev/null +++ b/Projects/Core/Network/Tests/Support/TokenStubs.swift @@ -0,0 +1,57 @@ +import CoreNetwork +import Foundation + +actor StubTokenProvider: TokenProviding { + private(set) var token: String? + private(set) var generation = 0 + + init(token: String? = "access-token") { + self.token = token + } + + func accessToken() async throws -> String? { + token + } + + func setToken(_ token: String?) { + self.token = token + generation += 1 + } +} + +actor StubTokenRefresher: TokenRefreshing { + private(set) var refreshCount = 0 + private var error: Error? + private var delayNanoseconds: UInt64 = 0 + private let provider: StubTokenProvider? + private let nextToken: String? + + init( + provider: StubTokenProvider? = nil, + nextToken: String? = "access-token-refreshed" + ) { + self.provider = provider + self.nextToken = nextToken + } + + func setError(_ error: Error?) { + self.error = error + } + + func setDelayNanoseconds(_ value: UInt64) { + delayNanoseconds = value + } + + func refresh() async throws { + refreshCount += 1 + if delayNanoseconds > 0 { + try await Task.sleep(nanoseconds: delayNanoseconds) + } + if let error { + throw error + } + if let provider, let nextToken { + await provider.setToken(nextToken) + } + } +} diff --git a/Projects/Core/Network/Tests/Support/URLProtocolStub.swift b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift new file mode 100644 index 0000000..0ac7518 --- /dev/null +++ b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift @@ -0,0 +1,103 @@ +import Foundation + +final class URLProtocolStub: URLProtocol, @unchecked Sendable { + struct StubResponse: Sendable { + let statusCode: Int + let headers: [String: String] + let data: Data + } + + private static let lock = NSLock() + nonisolated(unsafe) private static var _requestHandler: + (@Sendable (URLRequest) throws -> StubResponse)? + nonisolated(unsafe) private static var _requests: [URLRequest] = [] + + static var requestHandler: (@Sendable (URLRequest) throws -> StubResponse)? { + get { lock.withLock { _requestHandler } } + set { lock.withLock { _requestHandler = newValue } } + } + + static var requests: [URLRequest] { + lock.withLock { _requests } + } + + static func reset() { + lock.withLock { + _requestHandler = nil + _requests.removeAll() + } + } + + override static func canInit(with request: URLRequest) -> Bool { true } + override static func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + let observedRequest = Self.materializedRequest(from: request) + Self.lock.withLock { Self._requests.append(observedRequest) } + + do { + guard let handler = Self.requestHandler else { + throw NSError(domain: "URLProtocolStub", code: 1) + } + let stub = try handler(observedRequest) + guard let url = observedRequest.url ?? URL(string: "https://example.invalid") else { + throw NSError(domain: "URLProtocolStub", code: 2) + } + guard let response = HTTPURLResponse( + url: url, + statusCode: stub.statusCode, + httpVersion: "HTTP/1.1", + headerFields: stub.headers + ) else { + throw NSError(domain: "URLProtocolStub", code: 3) + } + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: stub.data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + private static func materializedRequest(from request: URLRequest) -> URLRequest { + var copy = request + if copy.httpBody == nil, let stream = copy.httpBodyStream { + stream.open() + defer { stream.close() } + + let bufferSize = 1024 + var data = Data() + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let readCount = stream.read(buffer, maxLength: bufferSize) + if readCount > 0 { + data.append(buffer, count: readCount) + } else { + break + } + } + copy.httpBody = data + } + return copy + } +} + +enum TestSessionFactory { + static func make() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [URLProtocolStub.self] + return URLSession(configuration: config) + } +} + +private extension NSLock { + func withLock(_ body: () -> T) -> T { + lock() + defer { unlock() } + return body() + } +} diff --git a/Projects/Core/Network/Tests/URLRequestBuildingTests.swift b/Projects/Core/Network/Tests/URLRequestBuildingTests.swift new file mode 100644 index 0000000..141b8b9 --- /dev/null +++ b/Projects/Core/Network/Tests/URLRequestBuildingTests.swift @@ -0,0 +1,35 @@ +import CoreNetwork +import XCTest + +final class URLRequestBuildingTests: XCTestCase { + func test_endpointDefaults_areEmpty() { + struct MinimalEndpoint: APIEndpoint { + let path = "/ping" + let method = HTTPMethod.get + } + + let endpoint = MinimalEndpoint() + XCTAssertEqual(endpoint.headers, [:]) + XCTAssertEqual(endpoint.queryItems, []) + XCTAssertNil(endpoint.body) + } + + func test_networkConfiguration_defaults() throws { + let baseURL = try XCTUnwrap(URL(string: "https://api.example.com")) + let configuration = NetworkConfiguration(baseURL: baseURL) + XCTAssertEqual(configuration.baseURL, baseURL) + XCTAssertEqual(configuration.timeout, 30) + _ = configuration.jsonDecoder + _ = configuration.jsonEncoder + } + + func test_tokenStubs_compile() async throws { + let provider = StubTokenProvider() + let refresher = StubTokenRefresher() + let token = try await provider.accessToken() + XCTAssertEqual(token, "access-token") + try await refresher.refresh() + let count = await refresher.refreshCount + XCTAssertEqual(count, 1) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9d0e89a..079305b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ Projects/ Feature → Domain, SharedUtils, SharedDesignSystem, SharedLogger, ThirdParty, ThirdPartyUI Data → Domain, Core/*, SharedLogger, SharedUtils Domain → SharedUtils, ThirdParty -Core/* → SharedUtils, ThirdPartyCore +Core/* → SharedUtils, SharedLogger, ThirdPartyCore SharedLogger → SharedUtils, OSLog App → 조립 ``` diff --git a/docs/CONVENTIONS.md b/docs/CONVENTIONS.md index 34eee90..340b97b 100644 --- a/docs/CONVENTIONS.md +++ b/docs/CONVENTIONS.md @@ -13,6 +13,7 @@ - live 조립은 App only - Scene 통신은 delegate bubble-up - SharedUtils는 pure Foundation / App metadata 만. UI·I/O·Domain 금지 +- CoreNetwork uses SharedLogger (`.network`) and never contains service-specific domain flows ## Schemes