-
Notifications
You must be signed in to change notification settings - Fork 0
CoreNetwork HTTP 인프라 추가 #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
3add9ac
chore: CoreNetwork 테스트 타겟과 SharedLogger 의존 추가
gnoes-ios 7f476fa
feat: CoreNetwork endpoint와 에러 모델 추가
gnoes-ios 475724a
feat: CoreNetwork 설정·토큰 포트·로깅 헬퍼 추가
gnoes-ios 6029451
feat: DefaultNetworkClient 요청 파이프라인 추가
gnoes-ios 9c1db65
test: CoreNetwork 단위 테스트 추가
gnoes-ios 67e0ffb
docs: Core 모듈 SharedLogger 의존 문서 반영
gnoes-ios 34b28b6
fix: CoreNetwork 린트와 concurrent 테스트 안정화
gnoes-ios 48a30f2
chore: SwiftLint 초안 규칙 완화
gnoes-ios cd75909
fix: CoreNetwork 리뷰 반영
gnoes-ios b3fbccf
test: CoreNetwork 리뷰 반영 검증 추가
gnoes-ios File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| public protocol TokenProviding: Sendable { | ||
| func accessToken() async throws -> String? | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| public protocol TokenRefreshing: Sendable { | ||
| func refresh() async throws | ||
| } |
227 changes: 227 additions & 0 deletions
227
Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Void, Error>? | ||
| 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<T: Decodable & Sendable>(_ 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) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| 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 | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| private struct PreparedRequest: Sendable { | ||
| let request: URLRequest | ||
| let accessTokenGeneration: Int? | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| import Foundation | ||
|
|
||
| public protocol NetworkClient: Sendable { | ||
| func request<T: Decodable & Sendable>(_ endpoint: some APIEndpoint) async throws -> T | ||
| func request(_ endpoint: some APIEndpoint) async throws | ||
| } |
20 changes: 20 additions & 0 deletions
20
Projects/Core/Network/Sources/Config/NetworkConfiguration.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
47 changes: 47 additions & 0 deletions
47
Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| }() | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| public enum HTTPMethod: String, Sendable { | ||
| case get = "GET" | ||
| case post = "POST" | ||
| case put = "PUT" | ||
| case patch = "PATCH" | ||
| case delete = "DELETE" | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| struct ErrorMessageDTO: Decodable, Sendable { | ||
| let message: String | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.