Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6de59de
[Chore] #35 - 기본응답스펙 변경에 따른 수정
Remaked-Swain Jan 12, 2026
9aca394
[Chore] #35 - 실패 시 기본응답 추가
Remaked-Swain Jan 12, 2026
e412f09
[Chore] #35 - DTO 네이밍 수정
Remaked-Swain Jan 12, 2026
52c11c6
[Add] #35 - 관련 DTO 정의
Remaked-Swain Jan 12, 2026
26ba2d5
[Add] #35 - Auth Feature 관련 Entity, DTO 추가
Remaked-Swain Jan 12, 2026
5f9055d
[Add] #35 - 인증실패로 인한 재시도 로직에서 쓰일 토큰재발급 역할의 타입 추가
Remaked-Swain Jan 12, 2026
a764ecd
[Del] #35 - 불필요 Mock코드 정리
Remaked-Swain Jan 12, 2026
124b33f
[Feat] #35 - 토큰 저장소 추가
Remaked-Swain Jan 12, 2026
2648007
[Add] #35 - 임시 파일 추가
Remaked-Swain Jan 12, 2026
65d030b
[Chore] #35 - 기본실패응답 DTO 추가로 인한 변경
Remaked-Swain Jan 12, 2026
3d2ac2d
[Chore] #35 - 네트워크 요청 전처리 과정을 위한 수정
Remaked-Swain Jan 12, 2026
7203103
[Refactor] #35 - 네트워킹 모델 + 토큰 저장소 + 재시도 로직 개선
Remaked-Swain Jan 12, 2026
e7f39a9
[Chore] #35 - 의존성 식별자
Remaked-Swain Jan 12, 2026
e3eb7f5
[Chore] #35 - 토큰갱신 후 재시도 재귀호출 로직 누락 수정 (리뷰 반영)
Remaked-Swain Jan 14, 2026
f24095a
[Chore] #35 - 헤더삽입 메서드 심볼 변경
Remaked-Swain Jan 14, 2026
81d02e6
[Chore] #35 - AppSecret ignore
Remaked-Swain Jan 14, 2026
e04872b
[Feat] #35 - MultipartFormData 네트워킹 기능 추가
Remaked-Swain Jan 14, 2026
3fbccad
[Chore] #35 - 기본응답스펙 통일
Remaked-Swain Jan 17, 2026
01c7a52
Merge branch 'develop' into add/#35-auth-endpoint
Remaked-Swain Jan 17, 2026
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,6 @@ iOSInjectionProject/
**/xcshareddata/WorkspaceSettings.xcsettings

# End of https://www.toptal.com/developers/gitignore/api/macos,swift,xcode

# AppSecret
*.xcconfig

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 14 additions & 1 deletion Neki-iOS/Core/Sources/Network/Sources/Base/BaseResponseDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,23 @@

import Foundation

public struct BaseResponseDTO<T: Decodable>: Decodable {
public struct BaseSucceededResponseDTO<T: Decodable>: Decodable {
let status: Int
let message: String
let isSuccess: Bool
let data: T?

enum CodingKeys: String, CodingKey {
case message, data
case status = "resultCode"
case isSuccess = "success"
}
}
Comment on lines 11 to 21
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

아직 API 연결한 게 없어서 변수명 자체를 바꿔도 상관없었을 것 같긴한데 코딩키를 활용하셨군여

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resultCode-status는 크게 상관이 없지만 success 필드는 false도 들어올 수 있는 Bool 타입이라서 isSuccess라는 Swift식 네이밍을 선택했습니다.


public struct EmptyData: Decodable {}

public struct BaseFailedResponseDTO: Decodable {
let status: Int
let message: String
let timestamp: String
}
118 changes: 67 additions & 51 deletions Neki-iOS/Core/Sources/Network/Sources/Base/Endpoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,84 +7,100 @@

import Foundation

public enum HeaderType {
case noneHeader
case accessTokenHeader
case refreshTokenHeader
/// 인증 타입
public enum AuthorizationType {
/// 별도의 인증이 필요하지 않은 경우
case none
/// Authorization-Bearer
case bearer
/// 토큰 재발급
///
/// - Important: 토큰 재발급이라는 특수한 용도를 위한 설정입니다. 일반적인 요청에 사용하는 것은 권장하지 않습니다.
/// - Authors: SwainYun
case reissue
}

/// Content-Type 종류
public enum HTTPContentType {
case json
case multipart(boundary: String)
case raw
}

public protocol Endpoint {
var headerType: HeaderType { get }
var authorizationType: AuthorizationType { get }
var contentType: HTTPContentType { get }
var baseURL: String { get }
var path: String { get }
var method: HTTPMethodType { get }
var queryParameters: [String: String]? { get }
var body: Encodable? { get }
var query: [URLQueryItem]? { get }
var multipartItems: [MultipartItem]? { get }

func asURLRequest() throws -> URLRequest
}

extension Endpoint {
static var defaultEncoder: JSONEncoder {
let encoder = JSONEncoder()
return encoder
}
public var queryParameters: [String: String]? { nil }

var baseURL: String {
guard let urlString = Bundle.main.infoDictionary?["BASE_URL"] as? String else {
fatalError("🚨Base URL을 찾을 수 없습니다🚨")
}
return urlString
}
public var multipartItems: [MultipartItem]? { nil }

static let defaultEncoder: JSONEncoder = JSONEncoder()

public func asURLRequest() throws -> URLRequest {
guard let url = URL(string: baseURL)?.appending(path: path),
var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
let encoder = Self.defaultEncoder

guard var urlComponents = URLComponents(string: baseURL) else {
throw NetworkError.invalidURLError
}

if let query = query {
components.queryItems = query
let currentPath = urlComponents.path
urlComponents.path = currentPath + path

if let queryParameters = queryParameters, !queryParameters.isEmpty {
urlComponents.queryItems = queryParameters.map { URLQueryItem(name: $0.key, value: $0.value) }
}

guard let finalURL = components.url else {
guard let url = urlComponents.url else {
throw NetworkError.invalidURLError
}

var request = URLRequest(url: finalURL)
var request = URLRequest(url: url)
request.httpMethod = method.rawValue

let allHeaders = makeHeaders()
request.allHTTPHeaderFields = allHeaders

if let body = body {
do {
request.httpBody = try Self.defaultEncoder.encode(body)
} catch {
throw NetworkError.requestEncodingError
switch contentType {
case .json:
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let body = body { request.httpBody = try encoder.encode(body) }

case .multipart(let boundary):
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
var builder = MultipartItemBuilder(boundary: boundary)

if let items = multipartItems {
for item in items { try item.append(to: &builder) }
}

if let body = body {
let data = try encoder.encode(body)
guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
throw MultipartError.invalidBody
}

for (key, value) in dict {
let field = MultipartFormField(name: key, value: value)
try field.append(to: &builder)
}
}

request.httpBody = try builder.finalize()

case .raw:
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
guard let body = body as? Data else { throw MultipartError.invalidBody }
request.httpBody = body
}

return request
}


// TODO: - "Content-Type"외 다양한 헤더를 추가할 수 있도록 하기
func makeHeaders() -> [String: String] {
var headers: [String: String] = [
"Content-Type": "application/json"
]

switch headerType {
case .noneHeader:
break
case .accessTokenHeader:
// TODO: - 여기에 토큰 가져오는 로직 추가 (토큰매니저나 키체인매니저 등)
break
case .refreshTokenHeader:
// TODO: - 리프레시 토큰 로직 추가
break
}

return headers
}
}
90 changes: 90 additions & 0 deletions Neki-iOS/Core/Sources/Network/Sources/Base/Multipart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//
// Multipart.swift
// Neki-iOS
//
// Created by SwainYun on 1/14/26.
//

import Foundation

fileprivate extension Data {
mutating func append(_ string: String) throws {
guard let data = string.data(using: .utf8) else { throw MultipartError.unsupportedValueType }
append(data)
}
}

public protocol MultipartItem {
func append(to builder: inout MultipartItemBuilder) throws
}

public enum MultipartError: Error {
case unsupportedValueType
case invalidBody
}

/// 단순한 키-값 파라미터 멀티파트 아이템
public struct MultipartFormField: MultipartItem {
let name: String
let value: Any

public func append(to builder: inout MultipartItemBuilder) throws {
let stringValue: String

switch value {
case let string as String: stringValue = string
case let int as Int: stringValue = String(int)
case let bool as Bool: stringValue = bool ? "true" : "false"
case let double as Double: stringValue = String(double)
// TODO: Date 등 다양한 타입 지원이 필요하면 이곳에 케이스 추가
default: throw MultipartError.unsupportedValueType
}

try builder.append(value: stringValue, name: name)
}
}
Comment on lines +27 to +45
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

public struct의 프로퍼티에 public 접근자가 누락되었습니다.

MultipartFormFieldpublic struct로 선언되어 있지만, namevalue 프로퍼티가 internal(기본값)로 되어 있어 모듈 외부에서 인스턴스를 생성할 수 없습니다. 외부에서 사용하려면 public initializer 또는 public 프로퍼티가 필요합니다.

🔧 수정 제안
 /// 단순한 키-값 파라미터 멀티파트 아이템
 public struct MultipartFormField: MultipartItem {
-    let name: String
-    let value: Any
+    public let name: String
+    public let value: Any
+    
+    public init(name: String, value: Any) {
+        self.name = name
+        self.value = value
+    }
     
     public func append(to builder: inout MultipartItemBuilder) throws {
🧰 Tools
🪛 SwiftLint (0.57.0)

[Warning] 39-39: TODOs should be resolved (Date 등 다양한 타입 지원이 필요하면 이곳에 케이스...)

(todo)

🤖 Prompt for AI Agents
In `@Neki-iOS/Core/Sources/Network/Sources/Base/Multipart.swift` around lines 27 -
45, MultipartFormField is declared public but its stored properties name and
value are internal, preventing instantiation outside the module; make the
properties public or add a public initializer so external code can create
instances. Locate the public struct MultipartFormField and either mark its
properties as public (public let name: String; public let value: Any) or add a
public init(name:value:) initializer that assigns to the existing properties and
keep any existing access levels intact; ensure access levels are consistent so
the public type is constructible from other modules.


/// 파일 데이터 멀티파트 아이템
public struct MultipartFile: MultipartItem {
let name: String
let data: Data
let fileName: String
let mimeType: String

public func append(to builder: inout MultipartItemBuilder) throws {
try builder.append(data: data, name: name, fileName: fileName, mimeType: mimeType)
}
}
Comment on lines +48 to +57
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

동일한 접근자 누락 문제

MultipartFilepublic struct이지만 프로퍼티들이 internal로 되어 있어 모듈 외부에서 인스턴스를 생성할 수 없습니다.

🔧 수정 제안
 /// 파일 데이터 멀티파트 아이템
 public struct MultipartFile: MultipartItem {
-    let name: String
-    let data: Data
-    let fileName: String
-    let mimeType: String
+    public let name: String
+    public let data: Data
+    public let fileName: String
+    public let mimeType: String
+    
+    public init(name: String, data: Data, fileName: String, mimeType: String) {
+        self.name = name
+        self.data = data
+        self.fileName = fileName
+        self.mimeType = mimeType
+    }

     public func append(to builder: inout MultipartItemBuilder) throws {
🤖 Prompt for AI Agents
In `@Neki-iOS/Core/Sources/Network/Sources/Base/Multipart.swift` around lines 48 -
57, The struct MultipartFile is public but its stored properties (name, data,
fileName, mimeType) are internal, preventing callers outside the module from
constructing instances; make each property public and add an explicit public
initializer (public init(name:data:fileName:mimeType:)) so external code can
create MultipartFile instances while keeping the existing public func
append(to:) unchanged.


public struct MultipartItemBuilder {
private let boundary: String
private var body = Data()

public init(boundary: String) { self.boundary = boundary }

/// 일반 텍스트 파라미터 추가
public mutating func append(value: String, name: String) throws {
try body.append("--\(boundary)\r\n")
try body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n")
try body.append("\r\n") // 헤더와 본문 사이 공백
try body.append(value)
try body.append("\r\n")
}

/// 파일 데이터(이미지 등) 추가
public mutating func append(data: Data, name: String, fileName: String, mimeType: String) throws {
try body.append("--\(boundary)\r\n")
try body.append("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(fileName)\"\r\n")
try body.append("Content-Type: \(mimeType)\r\n")
try body.append("\r\n") // 헤더와 본문 사이 공백
body.append(data)
try body.append("\r\n")
}

/// 최종 Body 데이터 생성 (Closing Boundary 추가)
public func finalize() throws -> Data {
var finalBody = body
try finalBody.append("--\(boundary)--\r\n") // 끝을 알리는 -- 추가
return finalBody
}
}
4 changes: 2 additions & 2 deletions Neki-iOS/Core/Sources/Network/Sources/Base/NetworkError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import Foundation

public enum NetworkError: LocalizedError {
case apiError(message: String)
case apiError(BaseFailedResponseDTO)
case requestEncodingError
case responseDecodingError
case responseError
Expand All @@ -22,7 +22,7 @@ public enum NetworkError: LocalizedError {

public var errorDescription: String? {
switch self {
case .apiError(let message): return message
case .apiError(let dto): return dto.message
case .requestEncodingError: return "요청 인코딩에 실패했습니다."
case .responseDecodingError: return "응답 디코딩에 실패했습니다."
case .responseError: return "응답 오류가 발생했습니다."
Expand Down
14 changes: 14 additions & 0 deletions Neki-iOS/Core/Sources/Network/Sources/Base/TokenRefresher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// TokenRefresher.swift
// Neki-iOS
//
// Created by SwainYun on 1/12/26.
//

import Foundation

public protocol TokenRefresher: Sendable {
var destination: Endpoint { get }

func refresh(provider: NetworkProvider) async throws(NetworkError) -> AuthTokens
}
22 changes: 22 additions & 0 deletions Neki-iOS/Core/Sources/Network/Sources/Base/TokenStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// TokenStorage.swift
// Neki-iOS
//
// Created by SwainYun on 1/12/26.
//

import Foundation

public protocol TokenStorage {
typealias Query = [String: Any]

func store(_ tokens: AuthTokens) throws(TokenStorageError)
func fetch() throws(TokenStorageError) -> AuthTokens
func delete() throws(TokenStorageError)
}

public enum TokenStorageError: Error {
case unknown
case notFound
case conversionFailed
}
Loading