Skip to content
Merged
16 changes: 10 additions & 6 deletions .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -99,6 +102,7 @@ excluded:
- .build
- Derived
- DerivedData
- .derivedData
- .git
- Mozi.xcworkspace
- "**/*.xcodeproj"
4 changes: 3 additions & 1 deletion Projects/Core/Network/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ let project = ProjectFactory.framework(
.coreNetwork,
dependencies: [
.sharedUtils,
]
.sharedLogger,
],
includesTests: true
)
3 changes: 3 additions & 0 deletions Projects/Core/Network/Sources/Auth/TokenProviding.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
public protocol TokenProviding: Sendable {
func accessToken() async throws -> String?
}
3 changes: 3 additions & 0 deletions Projects/Core/Network/Sources/Auth/TokenRefreshing.swift
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 Projects/Core/Network/Sources/Client/DefaultNetworkClient.swift
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
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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)
Comment thread
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
}
}
Comment thread
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}

private struct PreparedRequest: Sendable {
let request: URLRequest
let accessTokenGeneration: Int?
}
6 changes: 6 additions & 0 deletions Projects/Core/Network/Sources/Client/NetworkClient.swift
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 Projects/Core/Network/Sources/Config/NetworkConfiguration.swift
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 Projects/Core/Network/Sources/Config/NetworkJSONCoding.swift
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
}()
}
15 changes: 15 additions & 0 deletions Projects/Core/Network/Sources/Endpoint/APIEndpoint.swift
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 }
}
7 changes: 7 additions & 0 deletions Projects/Core/Network/Sources/Endpoint/HTTPMethod.swift
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"
}
3 changes: 3 additions & 0 deletions Projects/Core/Network/Sources/Error/ErrorMessageDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
struct ErrorMessageDTO: Decodable, Sendable {
let message: String
}
Loading