Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
Empty file.
25 changes: 25 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/EndPointEncoder.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 62 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/Endpoint.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 13 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/NetworkConfig.swift
Original file line number Diff line number Diff line change
@@ -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() {}
}
50 changes: 50 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/NetworkError.swift
Original file line number Diff line number Diff line change
@@ -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 "요청한 리소스를 찾을 수 없습니다."
}
}
}
13 changes: 13 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/NetworkService.swift
Original file line number Diff line number Diff line change
@@ -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<T: Decodable & Sendable>(_ endpoint: any Endpoint, responseType: T.Type) async throws -> T
}
29 changes: 29 additions & 0 deletions Projects/Infra/DoriNetwork/Sources/Responses/SuccessResponse.swift
Original file line number Diff line number Diff line change
@@ -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<T: Decodable & Sendable>: Decodable, Sendable {
public let success: Bool
public let data: T?
public let error: ApiErrorResponse?
}
32 changes: 32 additions & 0 deletions Projects/Infra/DoriNetwork/Tests/EndPointEncoderTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
74 changes: 74 additions & 0 deletions Projects/Infra/DoriNetwork/Tests/EndpointTests.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
}
23 changes: 23 additions & 0 deletions Projects/Infra/DoriNetwork/Tests/NetworkErrorTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading