From 8a15e6698988e1708918b12737595153511f2042 Mon Sep 17 00:00:00 2001 From: kangddong Date: Wed, 11 Feb 2026 02:48:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Network=20Layer=20=EC=BD=94=EB=93=9C=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 --- .github/workflows/build.yml | 50 ++++++++ Project.swift | 17 +++ Projects/Core/DoriCore/Sources/.gitkeep | 0 .../DoriNetwork/Sources/EndPointEncoder.swift | 25 ++++ .../Infra/DoriNetwork/Sources/Endpoint.swift | 62 +++++++++ .../DoriNetwork/Sources/NetworkConfig.swift | 13 ++ .../DoriNetwork/Sources/NetworkError.swift | 50 ++++++++ .../DoriNetwork/Sources/NetworkService.swift | 13 ++ .../Sources/Responses/SuccessResponse.swift | 29 +++++ .../Tests/EndPointEncoderTests.swift | 32 +++++ .../DoriNetwork/Tests/EndpointTests.swift | 74 +++++++++++ .../DoriNetwork/Tests/NetworkErrorTests.swift | 23 ++++ .../Sources/NetworkLogger.swift | 78 ++++++++++++ .../Sources/NetworkServiceImpl.swift | 120 ++++++++++++++++++ 14 files changed, 586 insertions(+) create mode 100644 Projects/Core/DoriCore/Sources/.gitkeep create mode 100644 Projects/Infra/DoriNetwork/Sources/EndPointEncoder.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/Endpoint.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/NetworkConfig.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/NetworkError.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/NetworkService.swift create mode 100644 Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift create mode 100644 Projects/Infra/DoriNetwork/Tests/EndPointEncoderTests.swift create mode 100644 Projects/Infra/DoriNetwork/Tests/EndpointTests.swift create mode 100644 Projects/Infra/DoriNetwork/Tests/NetworkErrorTests.swift create mode 100644 Projects/Infra/DoriNetworkImpl/Sources/NetworkLogger.swift create mode 100644 Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce27d16..eb1f83b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,3 +45,53 @@ jobs: -skipMacroValidation \ -destination "generic/platform=iOS Simulator" \ clean build + + - name: Run unit tests (all *Tests schemes) + run: | + set -euo pipefail + + TEST_SCHEMES=$( + xcodebuild -workspace Dori-iOS.xcworkspace -list -json | /usr/bin/python3 -c ' +import json, sys +data = json.load(sys.stdin) +schemes = data.get("workspace", {}).get("schemes", []) or data.get("project", {}).get("schemes", []) +tests = [scheme for scheme in schemes if scheme.endswith("Tests")] +print("\n".join(tests)) +' + ) + + if [ -z "$TEST_SCHEMES" ]; then + echo "No *Tests schemes found. Skipping test step." + exit 0 + fi + + SIMULATOR_ID=$( + xcrun simctl list devices available -j | /usr/bin/python3 -c ' +import json, sys +devices = json.load(sys.stdin).get("devices", {}) +for runtime in sorted(devices.keys(), reverse=True): + if "iOS" not in runtime: + continue + for device in devices[runtime]: + if device.get("isAvailable") and "iPhone" in device.get("name", ""): + print(device["udid"]) + raise SystemExit(0) +raise SystemExit(1) +' + ) + + echo "Using simulator id: $SIMULATOR_ID" + echo "Test schemes:" + echo "$TEST_SCHEMES" + + while IFS= read -r scheme; do + [ -z "$scheme" ] && continue + echo "Running tests for scheme: $scheme" + xcodebuild \ + -workspace Dori-iOS.xcworkspace \ + -scheme "$scheme" \ + -configuration Debug \ + -skipMacroValidation \ + -destination "id=$SIMULATOR_ID" \ + test + done <<< "$TEST_SCHEMES" diff --git a/Project.swift b/Project.swift index 2ab307a..f6505e8 100644 --- a/Project.swift +++ b/Project.swift @@ -27,6 +27,22 @@ func framework( ) } +func doriUnitTests( + name: String, + dependencies: [TargetDependency] = [] +) -> Target { + .target( + name: "\(name)Tests", + destinations: .iOS, + product: .unitTests, + bundleId: "com.arex.dori.\(name)Tests", + deploymentTargets: deploymentTarget, + sources: ["\(name)/Tests/**"], + dependencies: [.target(name: name)] + dependencies, + settings: .settings(base: commonSettings) + ) +} + let project = Project( name: "Dori-iOS", organizationName: "com.arex", @@ -69,6 +85,7 @@ let project = Project( // Infra framework(name: "DoriNetwork", path: "Projects/Infra/DoriNetwork"), + doriUnitTests(name: "DoriNetwork"), framework(name: "DoriNetworkImpl", path: "Projects/Infra/DoriNetworkImpl", dependencies: [ .target(name: "DoriNetwork"), diff --git a/Projects/Core/DoriCore/Sources/.gitkeep b/Projects/Core/DoriCore/Sources/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Projects/Infra/DoriNetwork/Sources/EndPointEncoder.swift b/Projects/Infra/DoriNetwork/Sources/EndPointEncoder.swift new file mode 100644 index 0000000..1a9bd06 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/EndPointEncoder.swift @@ -0,0 +1,25 @@ +// +// EndPointEncoder.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +public protocol EndPointEncoder { + func encode(_ value: Encodable) -> [String: String] +} + +public struct DefaultQueryEncoder: EndPointEncoder { + public init() {} + public func encode(_ value: Encodable) -> [String: String] { + let encoder = JSONEncoder() + guard + let data = try? encoder.encode(value), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return [:] } + + return object + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/Endpoint.swift b/Projects/Infra/DoriNetwork/Sources/Endpoint.swift new file mode 100644 index 0000000..06c802c --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Endpoint.swift @@ -0,0 +1,62 @@ +// +// Endpoint.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +// MARK: - HTTP Method +public enum HTTPMethod: String, Sendable { + case GET + case POST + case PUT + case DELETE + case PATCH +} + +// MARK: - Base Endpoint Protocol +public protocol Endpoint: Sendable { + var baseURL: String { get } + var path: String { get } + var method: HTTPMethod { get } + var headers: [String: String] { get } + var queryParameters: [String: String] { get } + var queryEncoder: EndPointEncoder { get } + var body: Data? { get } +} + +public extension Endpoint { + var queryEncoder: EndPointEncoder { DefaultQueryEncoder() } + + var defaultHeaders: [String: String] { + [ + "Content-Type": "application/json", + "accept": "application/json" + ] + } + + func createURL() -> URL? { + var urlComponents = URLComponents(string: baseURL.appending(path)) + var queryItems = [URLQueryItem]() + + queryParameters.forEach { + queryItems.append(URLQueryItem(name: $0.key, value: "\($0.value)")) + } + urlComponents?.queryItems = queryItems + + return urlComponents?.url + } + + func createURLRequest() throws -> URLRequest { + guard let url = createURL() else { throw NetworkError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + defaultHeaders.forEach { request.setValue($1, forHTTPHeaderField: $0)} + headers.forEach { request.setValue($1, forHTTPHeaderField: $0)} + request.httpBody = body + return request + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/NetworkConfig.swift b/Projects/Infra/DoriNetwork/Sources/NetworkConfig.swift new file mode 100644 index 0000000..984eeac --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/NetworkConfig.swift @@ -0,0 +1,13 @@ +// +// NetworkConfig.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +public struct NetworkConfig { + public static let baseURL: String = (Bundle.main.object(forInfoDictionaryKey: "BASE_URL") as? String) ?? "" + private init() {} +} diff --git a/Projects/Infra/DoriNetwork/Sources/NetworkError.swift b/Projects/Infra/DoriNetwork/Sources/NetworkError.swift new file mode 100644 index 0000000..b17227e --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/NetworkError.swift @@ -0,0 +1,50 @@ +// +// NetworkError.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +// MARK: - Network Error +public enum NetworkError: Error, LocalizedError { + case invalidURL + case invalidResponse + case noData + case encoding(_ error: Error) + case decoding(_ error: Error) + case network(_ error: Error) + case http(statusCode: Int, message: String) + case unauthorized + case forbidden + case notFound + + public var errorDescription: String? { + switch self { + case .invalidURL: + return "잘못된 URL입니다." + case .invalidResponse: + return "잘못된 응답입니다." + case .noData: + return "데이터가 없습니다." + case .encoding(let error): + return "인코딩 오류: \(error.localizedDescription)" + case .decoding(let error): + return "데이터 파싱 오류: \(error.localizedDescription)" + case .network(let error): + return "네트워크 오류: \(error.localizedDescription)" + case .http(let statusCode, let message): + if message.isEmpty { + return "HTTP 오류: \(statusCode)" + } + return "HTTP 오류 (\(statusCode)): \(message)" + case .unauthorized: + return "토큰이 만료되었습니다. 인증이 필요합니다." + case .forbidden: + return "접근이 금지되었습니다." + case .notFound: + return "요청한 리소스를 찾을 수 없습니다." + } + } +} diff --git a/Projects/Infra/DoriNetwork/Sources/NetworkService.swift b/Projects/Infra/DoriNetwork/Sources/NetworkService.swift new file mode 100644 index 0000000..fa558ed --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/NetworkService.swift @@ -0,0 +1,13 @@ +// +// NetworkService.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +// MARK: - Network Interface Protocol +public protocol NetworkService: Sendable { + func request(_ endpoint: any Endpoint, responseType: T.Type) async throws -> T +} diff --git a/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift b/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift new file mode 100644 index 0000000..fef2ebf --- /dev/null +++ b/Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift @@ -0,0 +1,29 @@ +// +// SuccessResponse.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +// MARK: - API Error Response +public struct ApiErrorResponse: Codable, Equatable, Sendable { + public let code: String + public let message: String? + + public init( + code: String, + message: String? = nil + ) { + self.code = code + self.message = message + } +} + +// MARK: - Generic Success Response +public struct SuccessResponse: Decodable, Sendable { + public let success: Bool + public let data: T? + public let error: ApiErrorResponse? +} diff --git a/Projects/Infra/DoriNetwork/Tests/EndPointEncoderTests.swift b/Projects/Infra/DoriNetwork/Tests/EndPointEncoderTests.swift new file mode 100644 index 0000000..3743e9d --- /dev/null +++ b/Projects/Infra/DoriNetwork/Tests/EndPointEncoderTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import DoriNetwork + +final class EndPointEncoderTests: XCTestCase { + private struct StringQuery: Encodable { + let keyword: String + let page: String + } + + private struct NonStringQuery: Encodable { + let page: Int + } + + func testEncode_whenAllValuesAreString_returnsDictionary() { + let encoder = DefaultQueryEncoder() + let query = StringQuery(keyword: "dori", page: "1") + + let encoded = encoder.encode(query) + + XCTAssertEqual(encoded["keyword"], "dori") + XCTAssertEqual(encoded["page"], "1") + } + + func testEncode_whenValueContainsNonString_returnsEmptyDictionary() { + let encoder = DefaultQueryEncoder() + let query = NonStringQuery(page: 1) + + let encoded = encoder.encode(query) + + XCTAssertTrue(encoded.isEmpty) + } +} diff --git a/Projects/Infra/DoriNetwork/Tests/EndpointTests.swift b/Projects/Infra/DoriNetwork/Tests/EndpointTests.swift new file mode 100644 index 0000000..0b99565 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Tests/EndpointTests.swift @@ -0,0 +1,74 @@ +import XCTest +@testable import DoriNetwork + +final class EndpointTests: XCTestCase { + private struct StubEndpoint: Endpoint { + let baseURL: String + let path: String + let method: HTTPMethod + let headers: [String: String] + let queryParameters: [String: String] + let body: Data? + } + + func testCreateURL_includesPathAndQueryParameters() throws { + let endpoint = StubEndpoint( + baseURL: "https://api.example.com", + path: "/v1/users", + method: .GET, + headers: [:], + queryParameters: ["page": "1", "size": "20"], + body: nil + ) + + let url = try XCTUnwrap(endpoint.createURL()) + let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)) + let queryDictionary = Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) + + XCTAssertEqual(components.scheme, "https") + XCTAssertEqual(components.host, "api.example.com") + XCTAssertEqual(components.path, "/v1/users") + XCTAssertEqual(queryDictionary["page"], "1") + XCTAssertEqual(queryDictionary["size"], "20") + } + + func testCreateURLRequest_setsMethodHeadersAndBody() throws { + let body = #"{"id":1}"#.data(using: .utf8) + let endpoint = StubEndpoint( + baseURL: "https://api.example.com", + path: "/v1/users", + method: .POST, + headers: [ + "Authorization": "Bearer token", + "Content-Type": "application/vnd.api+json", + ], + queryParameters: [:], + body: body + ) + + let request = try endpoint.createURLRequest() + + XCTAssertEqual(request.httpMethod, HTTPMethod.POST.rawValue) + XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer token") + XCTAssertEqual(request.value(forHTTPHeaderField: "Content-Type"), "application/vnd.api+json") + XCTAssertEqual(request.value(forHTTPHeaderField: "accept"), "application/json") + XCTAssertEqual(request.httpBody, body) + } + + func testCreateURLRequest_whenURLIsInvalid_throwsInvalidURL() { + let endpoint = StubEndpoint( + baseURL: "", + path: "", + method: .GET, + headers: [:], + queryParameters: [:], + body: nil + ) + + XCTAssertThrowsError(try endpoint.createURLRequest()) { error in + guard case NetworkError.invalidURL = error else { + return XCTFail("Expected NetworkError.invalidURL, got \(error)") + } + } + } +} diff --git a/Projects/Infra/DoriNetwork/Tests/NetworkErrorTests.swift b/Projects/Infra/DoriNetwork/Tests/NetworkErrorTests.swift new file mode 100644 index 0000000..5aeebd2 --- /dev/null +++ b/Projects/Infra/DoriNetwork/Tests/NetworkErrorTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import DoriNetwork + +final class NetworkErrorTests: XCTestCase { + func testErrorDescription_forHTTPErrorWithEmptyMessage() { + let error = NetworkError.http(statusCode: 500, message: "") + + XCTAssertEqual(error.errorDescription, "HTTP 오류: 500") + } + + func testErrorDescription_forHTTPErrorWithMessage() { + let error = NetworkError.http(statusCode: 400, message: "bad request") + + XCTAssertEqual(error.errorDescription, "HTTP 오류 (400): bad request") + } + + func testErrorDescription_forNetworkErrorIncludesLocalizedDescription() { + let underlying = NSError(domain: "test", code: -1, userInfo: [NSLocalizedDescriptionKey: "offline"]) + let error = NetworkError.network(underlying) + + XCTAssertEqual(error.errorDescription, "네트워크 오류: offline") + } +} diff --git a/Projects/Infra/DoriNetworkImpl/Sources/NetworkLogger.swift b/Projects/Infra/DoriNetworkImpl/Sources/NetworkLogger.swift new file mode 100644 index 0000000..26c6998 --- /dev/null +++ b/Projects/Infra/DoriNetworkImpl/Sources/NetworkLogger.swift @@ -0,0 +1,78 @@ +// +// NetworkLogger.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation + +public protocol NetworkLogable: Sendable { + func requestLogger(request: URLRequest) + func responseLogger(response: URLResponse, data: Data) +} +// MARK: - Logger +#if DEBUG +public final class NetworkLogger: NetworkLogable { + public init() {} + + public func requestLogger(request: URLRequest) { + print("") + debugPrint("======================== 👉 Network Request Log 👈 ==========================") + debugPrint("✅ [URL] : \(request.url?.absoluteString ?? "")") + debugPrint("✅ [Method] : \(request.httpMethod ?? "")") + debugPrint("✅ [Headers] : \(request.allHTTPHeaderFields ?? [:])") + + if let body = request.httpBody?.toPrettyPrintedString { + debugPrint("✅ [Body] : \(body)") + } else { + debugPrint("✅ [Body] : body 없음") + } + debugPrint("==============================================================================") + print("") + } + + public func responseLogger(response: URLResponse, data: Data) { + print("") + debugPrint("======================== 👉 Network Response Log 👈 ==========================") + + guard let response = response as? HTTPURLResponse else { + debugPrint("✅ [Response] : HTTPURLResponse 캐스팅 실패") + return + } + + debugPrint("✅ [StatusCode] : \(response.statusCode)") + + switch response.statusCode { + case 400..<500: + debugPrint("🚨 클라이언트 오류") + case 500..<600: + debugPrint("🚨 서버 오류") + default: + break + } + + debugPrint("✅ [ResponseData] : \(data.toPrettyPrintedString ?? "")") + debugPrint("===============================================================================") + print("") + } +} +#else +public final class Logger: NetworkLogable { + public init() {} + public func requestLogger(request: URLRequest) {} + public func responseLogger(response: URLResponse, data: Data) {} +} +#endif + +fileprivate extension Data { + var toPrettyPrintedString: String? { + guard let object = try? JSONSerialization.jsonObject(with: self, options: []), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted]), + let prettyPrintedString = NSString(data: data, encoding: String.Encoding.utf8.rawValue) + else { + return nil + } + return prettyPrintedString as String + } +} diff --git a/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift new file mode 100644 index 0000000..57b510d --- /dev/null +++ b/Projects/Infra/DoriNetworkImpl/Sources/NetworkServiceImpl.swift @@ -0,0 +1,120 @@ +// +// NetworkServiceImpl.swift +// Dori-iOS +// +// Created by 강동영 on 2/7/26. +// + +import Foundation +import Alamofire +import DoriNetwork + +public final class NetworkServiceImpl: NetworkService { + + // MARK: - Properties + private let session: Session + private let decoder: JSONDecoder + private let logger: NetworkLogable? + + public init(configuration: URLSessionConfiguration, logger: NetworkLogable? = nil) { + configuration.timeoutIntervalForRequest = 20 + self.session = Session(configuration: configuration) + self.logger = logger + self.decoder = JSONDecoder() + } + + public func request(_ endpoint: any Endpoint, responseType: T.Type) async throws -> T { + do { + let urlRequest = try endpoint.createURLRequest() + +#if DEBUG + logger?.requestLogger(request: urlRequest) +#endif + + let dataTask = session.request(urlRequest) + .validate() + .serializingDecodable(T.self, decoder: decoder) + + let response = await dataTask.response + + // 에러 체크 + if let error = response.error { + print("❌ Network Error: \(error.localizedDescription)") + throw mapAlamofireError(error) + } + +#if DEBUG + guard let httpResponse = response.response, let data = response.data else { + throw NetworkError.invalidResponse + } + self.logger?.responseLogger(response: httpResponse, data: data) +#endif + + return try await dataTask.value + } catch let error as AFError { + throw mapAlamofireError(error) + } catch let error as DecodingError { + throw mapError(error) + } catch { + throw mapError(error) + } + } + + // MARK: - Private Methods + private func alamofireMethod(from httpMethod: DoriNetwork.HTTPMethod) -> Alamofire.HTTPMethod { + switch httpMethod { + case .GET: + return .get + case .POST: + return .post + case .PUT: + return .put + case .DELETE: + return .delete + case .PATCH: + return .patch + } + } + + private func alamofireHeaders(from headers: [String: String]?) -> Alamofire.HTTPHeaders? { + guard let headers = headers else { return nil } + return Alamofire.HTTPHeaders(headers) + } + + private func mapAlamofireError(_ error: AFError) -> NetworkError { + switch error { + case .responseValidationFailed(let reason): + switch reason { + case .unacceptableStatusCode(let code): + switch code { + case 401: + return .unauthorized + case 403: + return .forbidden + case 404: + return .notFound + default: + return .http(statusCode: code, message: "") + } + default: + return .network(error) + } + case .responseSerializationFailed: + return .decoding(error) + default: + return .network(error) + } + } + + private func mapError(_ error: Error) -> NetworkError { + if let networkError = error as? NetworkError { + return networkError + } else if error is DecodingError { + return NetworkError.decoding(error) + } else if let afError = error as? AFError { + return mapAlamofireError(afError) + } else { + return NetworkError.network(error) + } + } +}