From 3add9ac087f1ffb0ae449d41937fb81d9ccb2810 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 01/10] =?UTF-8?q?chore:=20CoreNetwork=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=83=80=EA=B2=9F=EA=B3=BC=20SharedLogger=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/Core/Network/Project.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ) From 7f476fa92960afe3c4f355ecdf70beb252633043 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20CoreNetwork=20endpoint=EC=99=80?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Sources/Endpoint/APIEndpoint.swift | 15 +++++++++++++++ .../Network/Sources/Endpoint/HTTPMethod.swift | 7 +++++++ .../Network/Sources/Error/ErrorMessageDTO.swift | 3 +++ .../Core/Network/Sources/Error/NetworkError.swift | 14 ++++++++++++++ Projects/Core/Network/Sources/Placeholder.swift | 1 - 5 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 Projects/Core/Network/Sources/Endpoint/APIEndpoint.swift create mode 100644 Projects/Core/Network/Sources/Endpoint/HTTPMethod.swift create mode 100644 Projects/Core/Network/Sources/Error/ErrorMessageDTO.swift create mode 100644 Projects/Core/Network/Sources/Error/NetworkError.swift delete mode 100644 Projects/Core/Network/Sources/Placeholder.swift 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..ec2f670 --- /dev/null +++ b/Projects/Core/Network/Sources/Error/NetworkError.swift @@ -0,0 +1,14 @@ +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 serverError(statusCode: Int, message: String?) + case transport(message: String) +} diff --git a/Projects/Core/Network/Sources/Placeholder.swift b/Projects/Core/Network/Sources/Placeholder.swift deleted file mode 100644 index e998b93..0000000 --- a/Projects/Core/Network/Sources/Placeholder.swift +++ /dev/null @@ -1 +0,0 @@ -public enum CoreNetworkPlaceholder {} From 475724a40acea7283a697d67f80e4d7335a2467f Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20CoreNetwork=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=C2=B7=ED=86=A0=ED=81=B0=20=ED=8F=AC=ED=8A=B8=C2=B7=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=20=ED=97=AC=ED=8D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Network/Sources/Auth/TokenProviding.swift | 3 + .../Sources/Auth/TokenRefreshing.swift | 3 + .../Sources/Config/NetworkConfiguration.swift | 20 ++++++ .../Sources/Config/NetworkJSONCoding.swift | 47 ++++++++++++++ .../Network/Sources/Logging/NetworkLog.swift | 65 +++++++++++++++++++ 5 files changed, 138 insertions(+) create mode 100644 Projects/Core/Network/Sources/Auth/TokenProviding.swift create mode 100644 Projects/Core/Network/Sources/Auth/TokenRefreshing.swift create mode 100644 Projects/Core/Network/Sources/Config/NetworkConfiguration.swift create mode 100644 Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift create mode 100644 Projects/Core/Network/Sources/Logging/NetworkLog.swift 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/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/Logging/NetworkLog.swift b/Projects/Core/Network/Sources/Logging/NetworkLog.swift new file mode 100644 index 0000000..7d2d773 --- /dev/null +++ b/Projects/Core/Network/Sources/Logging/NetworkLog.swift @@ -0,0 +1,65 @@ +import Foundation +import SharedLogger + +enum NetworkLog { + static func request(_ request: URLRequest) { + let method = request.httpMethod ?? "NIL" + let url = request.url?.absoluteString ?? "nil" + 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 = url?.absoluteString ?? "nil" + 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 = url?.absoluteString ?? "nil" + Logger.shared.error("✕ \(target) \(error.localizedDescription)", category: .network) + } + + 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.. Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20DefaultNetworkClient=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Client/DefaultNetworkClient.swift | 202 ++++++++++++++++++ .../Sources/Client/NetworkClient.swift | 6 + 2 files changed, 208 insertions(+) create mode 100644 Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift create mode 100644 Projects/Core/Network/Sources/Client/NetworkClient.swift diff --git a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift new file mode 100644 index 0000000..6dbc355 --- /dev/null +++ b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift @@ -0,0 +1,202 @@ +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 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 request = try await makeURLRequest(for: endpoint) + NetworkLog.request(request) + + let started = Date() + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + NetworkLog.error(error, url: 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: request.url) + throw error + } + + NetworkLog.response( + statusCode: httpResponse.statusCode, + url: request.url, + data: data, + durationMs: durationMs + ) + + if httpResponse.statusCode == 401 { + if tokenRefresher != nil, allowRefresh { + try await refreshSingleFlight() + return try await send(endpoint, allowRefresh: false) + } + throw NetworkError.unauthorized + } + + 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 + refreshTask = nil + } catch { + refreshTask = nil + throw error + } + } + + private func makeURLRequest(for endpoint: some APIEndpoint) async throws -> URLRequest { + 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) + } + + if let tokenProvider { + if let token = try await tokenProvider.accessToken() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + } + + return request + } + + 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) + default: + return .serverError(statusCode: statusCode, message: message) + } + } +} 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 +} From 9c1db651e83e77e8f19b09370434778b0251c9dd Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 05/10] =?UTF-8?q?test:=20CoreNetwork=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tests/DefaultNetworkClientTests.swift | 235 ++++++++++++++++++ .../Tests/NetworkErrorMappingTests.swift | 62 +++++ .../Network/Tests/Support/TestEndpoint.swift | 24 ++ .../Network/Tests/Support/TokenStubs.swift | 30 +++ .../Tests/Support/URLProtocolStub.swift | 102 ++++++++ .../Tests/URLRequestBuildingTests.swift | 35 +++ 6 files changed, 488 insertions(+) create mode 100644 Projects/Core/Network/Tests/DefaultNetworkClientTests.swift create mode 100644 Projects/Core/Network/Tests/NetworkErrorMappingTests.swift create mode 100644 Projects/Core/Network/Tests/Support/TestEndpoint.swift create mode 100644 Projects/Core/Network/Tests/Support/TokenStubs.swift create mode 100644 Projects/Core/Network/Tests/Support/URLProtocolStub.swift create mode 100644 Projects/Core/Network/Tests/URLRequestBuildingTests.swift diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift new file mode 100644 index 0000000..f644db7 --- /dev/null +++ b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift @@ -0,0 +1,235 @@ +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 OK: 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: OK = try await client.request(endpoint) + XCTAssertEqual(response, OK(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 OK: 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 _: OK = 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 OK: 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: OK = 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 OK: 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() + ) + + async let a: OK = client.request(TestEndpoint(path: "/a")) + async let b: OK = client.request(TestEndpoint(path: "/b")) + async let c: OK = client.request(TestEndpoint(path: "/c")) + _ = try await (a, b, c) + + 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..be438ca --- /dev/null +++ b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift @@ -0,0 +1,62 @@ +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")), + (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 .serverError(_, message): + return message ?? "" + case .unauthorized: + return "unauthorized" + default: + return "" + } + } +} 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..a25435d --- /dev/null +++ b/Projects/Core/Network/Tests/Support/TokenStubs.swift @@ -0,0 +1,30 @@ +import CoreNetwork +import Foundation + +actor StubTokenProvider: TokenProviding { + var token: String? + + init(token: String? = "access-token") { + self.token = token + } + + func accessToken() async throws -> String? { + token + } +} + +actor StubTokenRefresher: TokenRefreshing { + private(set) var refreshCount = 0 + private var error: Error? + + func setError(_ error: Error?) { + self.error = error + } + + func refresh() async throws { + refreshCount += 1 + if let error { + throw error + } + } +} diff --git a/Projects/Core/Network/Tests/Support/URLProtocolStub.swift b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift new file mode 100644 index 0000000..9267376 --- /dev/null +++ b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift @@ -0,0 +1,102 @@ +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 class func canInit(with request: URLRequest) -> Bool { true } + override class 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) + } +} From 67e0ffb89f177a1d8a6811617fc2e90e1e614439 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:23:04 +0900 Subject: [PATCH 06/10] =?UTF-8?q?docs:=20Core=20=EB=AA=A8=EB=93=88=20Share?= =?UTF-8?q?dLogger=20=EC=9D=98=EC=A1=B4=20=EB=AC=B8=EC=84=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 2 +- docs/CONVENTIONS.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 From 34b28b67a3c6d516891a669610572c8622a2ba54 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 15:39:42 +0900 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20CoreNetwork=20=EB=A6=B0=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20concurrent=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Client/DefaultNetworkClient.swift | 4 +- .../Tests/DefaultNetworkClientTests.swift | 41 ++++++++++++------- .../Network/Tests/Support/TokenStubs.swift | 8 ++++ .../Tests/Support/URLProtocolStub.swift | 7 ++-- 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift index 6dbc355..0b26e71 100644 --- a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift +++ b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift @@ -183,7 +183,9 @@ public actor DefaultNetworkClient: NetworkClient { } private func mapStatusCode(_ statusCode: Int, data: Data) -> NetworkError { - let message = try? configuration.jsonDecoder.decode(ErrorMessageDTO.self, from: data).message + let message = try? configuration.jsonDecoder + .decode(ErrorMessageDTO.self, from: data) + .message switch statusCode { case 400: return .badRequest(message: message) diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift index f644db7..f5ce18b 100644 --- a/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift +++ b/Projects/Core/Network/Tests/DefaultNetworkClientTests.swift @@ -19,7 +19,7 @@ final class DefaultNetworkClientTests: XCTestCase { return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) } - struct OK: Decodable, Equatable { let ok: Bool } + 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), @@ -34,8 +34,8 @@ final class DefaultNetworkClientTests: XCTestCase { body: Data(#"{"accessToken":"k"}"#.utf8) ) - let response: OK = try await client.request(endpoint) - XCTAssertEqual(response, OK(ok: true)) + let response: OkPayload = try await client.request(endpoint) + XCTAssertEqual(response, OkPayload(ok: true)) } func test_voidRequest_succeedsOn2xxAndIgnoresBody() async throws { @@ -75,11 +75,14 @@ final class DefaultNetworkClientTests: XCTestCase { func test_authedRequest_attachesBearerToken() async throws { URLProtocolStub.requestHandler = { request in - XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer access-token") + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer access-token" + ) return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) } - struct OK: Decodable { let ok: Bool } + 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() @@ -90,7 +93,7 @@ final class DefaultNetworkClientTests: XCTestCase { session: TestSessionFactory.make() ) - let _: OK = try await client.request(TestEndpoint()) + let _: OkPayload = try await client.request(TestEndpoint()) } func test_authed401_refreshesOnceAndRetries() async throws { @@ -114,7 +117,7 @@ final class DefaultNetworkClientTests: XCTestCase { return .init(statusCode: 401, headers: [:], data: Data(#"{"message":"expired"}"#.utf8)) } - struct OK: Decodable, Equatable { let ok: Bool } + 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() @@ -125,7 +128,7 @@ final class DefaultNetworkClientTests: XCTestCase { session: TestSessionFactory.make() ) - let value: OK = try await client.request(TestEndpoint()) + let value: OkPayload = try await client.request(TestEndpoint()) XCTAssertEqual(value.ok, true) let refreshCount = await refresher.refreshCount XCTAssertEqual(refreshCount, 1) @@ -150,15 +153,21 @@ final class DefaultNetworkClientTests: XCTestCase { 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: 401, + headers: [:], + data: Data(#"{"message":"expired"}"#.utf8) + ) } return .init(statusCode: 200, headers: [:], data: Data(#"{"ok":true}"#.utf8)) } - struct OK: Decodable { let ok: Bool } + 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, @@ -166,10 +175,10 @@ final class DefaultNetworkClientTests: XCTestCase { session: TestSessionFactory.make() ) - async let a: OK = client.request(TestEndpoint(path: "/a")) - async let b: OK = client.request(TestEndpoint(path: "/b")) - async let c: OK = client.request(TestEndpoint(path: "/c")) - _ = try await (a, b, c) + 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) @@ -187,7 +196,9 @@ final class DefaultNetworkClientTests: XCTestCase { } let provider = StubTokenProvider(token: "access-token") let refresher = StubTokenRefresher() - await refresher.setError(NetworkError.serverError(statusCode: 500, message: "refresh failed")) + await refresher.setError( + NetworkError.serverError(statusCode: 500, message: "refresh failed") + ) let client = DefaultNetworkClient.authed( configuration: NetworkConfiguration(baseURL: baseURL), tokenProvider: provider, diff --git a/Projects/Core/Network/Tests/Support/TokenStubs.swift b/Projects/Core/Network/Tests/Support/TokenStubs.swift index a25435d..9152982 100644 --- a/Projects/Core/Network/Tests/Support/TokenStubs.swift +++ b/Projects/Core/Network/Tests/Support/TokenStubs.swift @@ -16,13 +16,21 @@ actor StubTokenProvider: TokenProviding { actor StubTokenRefresher: TokenRefreshing { private(set) var refreshCount = 0 private var error: Error? + private var delayNanoseconds: UInt64 = 0 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 } diff --git a/Projects/Core/Network/Tests/Support/URLProtocolStub.swift b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift index 9267376..0ac7518 100644 --- a/Projects/Core/Network/Tests/Support/URLProtocolStub.swift +++ b/Projects/Core/Network/Tests/Support/URLProtocolStub.swift @@ -8,7 +8,8 @@ final class URLProtocolStub: URLProtocol, @unchecked Sendable { } private static let lock = NSLock() - nonisolated(unsafe) private static var _requestHandler: (@Sendable (URLRequest) throws -> StubResponse)? + nonisolated(unsafe) private static var _requestHandler: + (@Sendable (URLRequest) throws -> StubResponse)? nonisolated(unsafe) private static var _requests: [URLRequest] = [] static var requestHandler: (@Sendable (URLRequest) throws -> StubResponse)? { @@ -27,8 +28,8 @@ final class URLProtocolStub: URLProtocol, @unchecked Sendable { } } - override class func canInit(with request: URLRequest) -> Bool { true } - override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + 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) From 48a30f27a69d32a8779ca70907a8accb08d9aba2 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 16:06:10 +0900 Subject: [PATCH 08/10] =?UTF-8?q?chore:=20SwiftLint=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=EA=B7=9C=EC=B9=99=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .swiftlint.yml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) 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" From cd75909044a56a043b25d531b49bed3e102cab96 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 16:06:10 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20CoreNetwork=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Sources/Client/DefaultNetworkClient.swift | 45 ++++++++++++++----- .../Network/Sources/Error/NetworkError.swift | 1 + .../Network/Sources/Logging/NetworkLog.swift | 16 +++++-- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift index 0b26e71..3a584f4 100644 --- a/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift +++ b/Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift @@ -6,6 +6,7 @@ public actor DefaultNetworkClient: NetworkClient { private let tokenProvider: (any TokenProviding)? private let tokenRefresher: (any TokenRefreshing)? private var refreshTask: Task? + private var accessTokenGeneration = 0 private init( configuration: NetworkConfiguration, @@ -68,39 +69,46 @@ public actor DefaultNetworkClient: NetworkClient { _ endpoint: some APIEndpoint, allowRefresh: Bool ) async throws -> (HTTPURLResponse, Data) { - let request = try await makeURLRequest(for: endpoint) - NetworkLog.request(request) + 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: request) + (data, response) = try await session.data(for: prepared.request) } catch { - NetworkLog.error(error, url: request.url) + 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: request.url) + NetworkLog.error(error, url: prepared.request.url) throw error } NetworkLog.response( statusCode: httpResponse.statusCode, - url: request.url, + url: prepared.request.url, data: data, durationMs: durationMs ) if httpResponse.statusCode == 401 { - if tokenRefresher != nil, allowRefresh { - try await refreshSingleFlight() + guard tokenRefresher != nil, allowRefresh else { + throw NetworkError.unauthorized + } + + // 이미 다른 요청이 refresh를 끝낸 뒤라면 중복 refresh 없이 재시도한다. + if let usedGeneration = prepared.accessTokenGeneration, + usedGeneration < accessTokenGeneration { return try await send(endpoint, allowRefresh: false) } - throw NetworkError.unauthorized + + try await refreshSingleFlight() + return try await send(endpoint, allowRefresh: false) } guard (200...299).contains(httpResponse.statusCode) else { @@ -130,6 +138,7 @@ public actor DefaultNetworkClient: NetworkClient { do { try await task.value + accessTokenGeneration += 1 refreshTask = nil } catch { refreshTask = nil @@ -137,7 +146,7 @@ public actor DefaultNetworkClient: NetworkClient { } } - private func makeURLRequest(for endpoint: some APIEndpoint) async throws -> URLRequest { + private func makeURLRequest(for endpoint: some APIEndpoint) async throws -> PreparedRequest { guard var components = URLComponents( url: configuration.baseURL.appendingPathComponent(normalizedPath(endpoint.path)), resolvingAgainstBaseURL: false @@ -166,13 +175,18 @@ public actor DefaultNetworkClient: NetworkClient { 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 request + return PreparedRequest(request: request, accessTokenGeneration: usedGeneration) } private func normalizedPath(_ path: String) -> String { @@ -197,8 +211,17 @@ public actor DefaultNetworkClient: NetworkClient { 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/Error/NetworkError.swift b/Projects/Core/Network/Sources/Error/NetworkError.swift index ec2f670..3c65df0 100644 --- a/Projects/Core/Network/Sources/Error/NetworkError.swift +++ b/Projects/Core/Network/Sources/Error/NetworkError.swift @@ -9,6 +9,7 @@ public enum NetworkError: Error, Equatable, Sendable { 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 index 7d2d773..aef3675 100644 --- a/Projects/Core/Network/Sources/Logging/NetworkLog.swift +++ b/Projects/Core/Network/Sources/Logging/NetworkLog.swift @@ -4,7 +4,7 @@ import SharedLogger enum NetworkLog { static func request(_ request: URLRequest) { let method = request.httpMethod ?? "NIL" - let url = request.url?.absoluteString ?? "nil" + let url = sanitizedURLString(request.url) var message = "→ \(method) \(url)" #if DEBUG @@ -22,7 +22,7 @@ enum NetworkLog { data: Data, durationMs: Int ) { - let target = url?.absoluteString ?? "nil" + let target = sanitizedURLString(url) var message = "← \(statusCode) \(target) (\(durationMs)ms, \(data.count)B)" #if DEBUG @@ -39,10 +39,20 @@ enum NetworkLog { } static func error(_ error: Error, url: URL?) { - let target = url?.absoluteString ?? "nil" + 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 = [ From b3fbccffbd01bcfd3395936ce7775916e9b6c2b4 Mon Sep 17 00:00:00 2001 From: "gnoes.ios" Date: Tue, 4 Aug 2026 16:06:10 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test:=20CoreNetwork=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DefaultNetworkClientReviewFixTests.swift | 96 +++++++++++++++++++ .../Tests/NetworkErrorMappingTests.swift | 3 + .../Core/Network/Tests/NetworkLogTests.swift | 15 +++ .../Network/Tests/Support/TokenStubs.swift | 21 +++- 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift create mode 100644 Projects/Core/Network/Tests/NetworkLogTests.swift diff --git a/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift b/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift new file mode 100644 index 0000000..00ec4b1 --- /dev/null +++ b/Projects/Core/Network/Tests/DefaultNetworkClientReviewFixTests.swift @@ -0,0 +1,96 @@ +import CoreNetwork +import XCTest + +final class DefaultNetworkClientReviewFixTests: XCTestCase { + override func tearDown() { + URLProtocolStub.reset() + super.tearDown() + } + + func test_authedHTTPURL_rejectsAuthorization() async { + URLProtocolStub.requestHandler = { _ in + XCTFail("request should not be sent") + return .init(statusCode: 200, headers: [:], data: Data()) + } + + guard let baseURL = URL(string: "http://api.example.invalid") else { + XCTFail("invalid base URL") + return + } + let provider = StubTokenProvider(token: "access-token") + let refresher = StubTokenRefresher(provider: provider) + 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 invalidURL") + } catch let error as NetworkError { + XCTAssertEqual(error, .invalidURL) + } catch { + XCTFail("unexpected \(error)") + } + } + + func test_delayed401_afterRefresh_doesNotRefreshTwice() async throws { + final class RequestCounter: @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func next() -> 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/NetworkErrorMappingTests.swift b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift index be438ca..6267048 100644 --- a/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift +++ b/Projects/Core/Network/Tests/NetworkErrorMappingTests.swift @@ -14,6 +14,8 @@ final class NetworkErrorMappingTests: XCTestCase { (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")), ] @@ -51,6 +53,7 @@ final class NetworkErrorMappingTests: XCTestCase { let .forbidden(message), let .notFound(message), let .conflict(message), + let .clientError(_, message), let .serverError(_, message): return message ?? "" case .unauthorized: 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/TokenStubs.swift b/Projects/Core/Network/Tests/Support/TokenStubs.swift index 9152982..91add89 100644 --- a/Projects/Core/Network/Tests/Support/TokenStubs.swift +++ b/Projects/Core/Network/Tests/Support/TokenStubs.swift @@ -2,7 +2,8 @@ import CoreNetwork import Foundation actor StubTokenProvider: TokenProviding { - var token: String? + private(set) var token: String? + private(set) var generation = 0 init(token: String? = "access-token") { self.token = token @@ -11,12 +12,27 @@ actor StubTokenProvider: TokenProviding { 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 @@ -34,5 +50,8 @@ actor StubTokenRefresher: TokenRefreshing { if let error { throw error } + if let provider, let nextToken { + await provider.setToken(nextToken) + } } }