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
10 changes: 7 additions & 3 deletions Projects/App/Sources/DI/Dependencies.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import Data
import Domain
import ThirdParty

enum Dependencies {
static func register(
_ values: inout DependencyValues,
infra: InfraContainer
) {
// TODO: Domain Client factory 등록
_ = infra
_ = values
values.authClient = .live(
baseURL: infra.configuration.baseURL,
keychain: infra.keychain,
oauthServices: OAuthServiceFactory.makeStub()
)
}
}
1 change: 1 addition & 0 deletions Projects/App/Sources/DI/InfraContainer.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CoreStorage
import Foundation

/// App 인프라 컨테이너. 설정과 로컬 저장소만 보유한다.
struct InfraContainer: Sendable {
let configuration: AppConfiguration
let userDefaults: any UserDefaultsStorage
Expand Down
6 changes: 6 additions & 0 deletions Projects/Data/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,11 @@ let project = ProjectFactory.framework(
.coreStorage,
.sharedLogger,
.sharedUtils,
],
includesTests: true,
testsDependencies: [
.domain,
.coreNetwork,
.coreStorage,
]
)
43 changes: 34 additions & 9 deletions Projects/Data/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,56 @@
# Data

## 책임
- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`
- DTO, Datasource, `*RepositoryImpl`, `*ClientFactory`, live 조립

## 현재 상태
- placeholder 골격
- Auth 구현 추가
- DTO/Endpoint/Datasource
- `AuthLocalDatasource`
- `AuthTokenRefresher`
- `AuthRepositoryImpl`
- `AuthClientFactory` (repository → Domain client adapter)
- `AuthClient.live` (plain/refresher/authed 풀 조립)
- `OAuthService` / `OAuthServiceFactory` (PR1 stub)

## 이후 패턴
- `Data/<Name>/{DTO,Datasource,Repository}`
- `Data/<Name>/{Client,DTO,Datasource,Repository,Service}`
- `*RepositoryImpl`
- `*ClientFactory`
- `*ClientFactory` + `*Client.live`

## 의존
- 허용: Domain, Core/*, SharedLogger, SharedUtils
- 금지: Feature

## 내부 규칙
- Domain `*Client` 의 live 구현을 factory 로 제공
- App 조립 지점에서 factory 등록
- Domain `*Client` 의 live 구현(`*Client.live`)은 Data 에서 제공
- App 은 pure infra 준비 + `prepareDependencies` 등록만 담당
- Feature 가 Data 를 직접 import 하지 않음
- refresh 는 plain client + `AuthTokenRefresher` 경로
- refresh 실패 시 unauthorized 만 로컬 세션 삭제, badRequest/일시 네트워크 오류는 세션 유지
- request body encode 는 Data remote 에서 수행하고 실패 시 throw
- OAuth credential 은 `OAuthService` 가 담당 (Souzip 스타일)

## 주요 진입점
- (현재) `Sources/Placeholder.swift`
- (이후) `*ClientFactory`
- `Sources/Auth/Client/AuthClient+Live.swift`
- `Sources/Auth/Client/AuthClientFactory.swift`
- `Sources/Auth/Repository/AuthRepositoryImpl.swift`
- `Sources/Auth/Datasource/AuthRemoteDatasource.swift`
- `Sources/Auth/Datasource/AuthLocalDatasource.swift`
- `Sources/Auth/Service/AuthTokenRefresher.swift`
- `Sources/Auth/Service/OAuth/OAuthService.swift`
- `Sources/Auth/Service/OAuth/OAuthServiceFactory.swift`
- `Sources/Auth/Endpoint/AuthEndpoint.swift`
- `Sources/Auth/DTO/*`

## 테스트 포인트
- repository/factory 매핑 (구현 시)
- DTO 매핑
- local datasource 저장/삭제
- repository login/restore/logout
- token refresher rotation 교체 저장
- logout 시 로컬 세션 삭제
- factory credential → repository 연결
- OAuth stub notConfigured

## 관련 문서
- [ARCHITECTURE.md](../../docs/ARCHITECTURE.md)
Expand Down
49 changes: 49 additions & 0 deletions Projects/Data/Sources/Auth/Client/AuthClient+Live.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import CoreNetwork
import CoreStorage
import Domain
import Foundation

public extension AuthClient {
/// Auth live 조립. plain refresh 경로로 refresher를 먼저 만들어 순환 의존을 끊는다.
static func live(
baseURL: URL,
keychain: any KeychainStorage,
oauthServices: OAuthServices
) -> AuthClient {
let networkConfiguration = NetworkConfiguration(baseURL: baseURL)
let plainNetworkClient = DefaultNetworkClient.plain(
configuration: networkConfiguration
)
let local = AuthLocalDatasource(keychain: keychain)

// refresh는 plain only. logout 경로가 없어 authed 자리에 plain을 넣는다.
let refreshRemote = AuthRemoteDatasource(
plainClient: plainNetworkClient,
authedClient: plainNetworkClient
)
let tokenRefresher = AuthTokenRefresher(
remote: refreshRemote,
local: local
)
let authedNetworkClient = DefaultNetworkClient.authed(
configuration: networkConfiguration,
tokenProvider: local,
tokenRefresher: tokenRefresher
)
let fullRemote = AuthRemoteDatasource(
plainClient: plainNetworkClient,
authedClient: authedNetworkClient
)
let repository = AuthRepositoryImpl(
remote: fullRemote,
local: local
)

return AuthClientFactory.make(
repository: repository,
credentialProvider: { provider in
try await oauthServices.service(for: provider).login()
}
Comment thread
gnoes-ios marked this conversation as resolved.
)
}
}
30 changes: 30 additions & 0 deletions Projects/Data/Sources/Auth/Client/AuthClientFactory.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Domain
import Foundation

public enum AuthClientFactory {
public static func make(
repository: AuthRepositoryImpl,
credentialProvider: @escaping @Sendable (AuthProvider) async throws -> String
) -> AuthClient {
AuthClient(
restoreSession: {
try await repository.restoreSession()
},
login: { provider in
let credential = try await credentialProvider(provider)
switch provider {
case .kakao:
return try await repository.loginWithKakao(accessToken: credential)
case .apple:
return try await repository.loginWithApple(identityToken: credential)
}
Comment thread
gnoes-ios marked this conversation as resolved.
},
logout: {
try await repository.logout()
},
currentSession: {
await repository.currentSession()
}
)
}
}
5 changes: 5 additions & 0 deletions Projects/Data/Sources/Auth/DTO/AppleLoginRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

struct AppleLoginRequestDTO: Encodable, Sendable {
let identityToken: String
}
6 changes: 6 additions & 0 deletions Projects/Data/Sources/Auth/DTO/DevLoginRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

struct DevLoginRequestDTO: Encodable, Sendable {
// 서버 계약이 빈 body 또는 고정 payload 면 그에 맞춤.
// OpenAPI 확인 불가 시 빈 object `{}` 로 보낸다.
}
5 changes: 5 additions & 0 deletions Projects/Data/Sources/Auth/DTO/KakaoLoginRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

struct KakaoLoginRequestDTO: Encodable, Sendable {
let accessToken: String
}
30 changes: 30 additions & 0 deletions Projects/Data/Sources/Auth/DTO/LoginResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Domain
import Foundation

public struct LoginResponseDTO: Decodable, Equatable, Sendable {
public let accessToken: String
public let refreshToken: String
public let isNewUser: Bool
public let profileCompleted: Bool

public init(
accessToken: String,
refreshToken: String,
isNewUser: Bool,
profileCompleted: Bool
) {
self.accessToken = accessToken
self.refreshToken = refreshToken
self.isNewUser = isNewUser
self.profileCompleted = profileCompleted
}

public func toDomain() -> AuthSession {
AuthSession(
accessToken: accessToken,
refreshToken: refreshToken,
isNewUser: isNewUser,
profileCompleted: profileCompleted
)
}
}
5 changes: 5 additions & 0 deletions Projects/Data/Sources/Auth/DTO/RefreshRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import Foundation

struct RefreshRequestDTO: Encodable, Sendable {
let refreshToken: String
}
21 changes: 21 additions & 0 deletions Projects/Data/Sources/Auth/DTO/TokenResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Domain
import Foundation

public struct TokenResponseDTO: Decodable, Equatable, Sendable {
public let accessToken: String
public let refreshToken: String

public init(accessToken: String, refreshToken: String) {
self.accessToken = accessToken
self.refreshToken = refreshToken
}

public func applying(to session: AuthSession) -> AuthSession {
AuthSession(
accessToken: accessToken,
refreshToken: refreshToken,
isNewUser: session.isNewUser,
profileCompleted: session.profileCompleted
)
}
}
30 changes: 30 additions & 0 deletions Projects/Data/Sources/Auth/Datasource/AuthLocalDatasource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import CoreNetwork
import CoreStorage
import Domain
import Foundation

public struct AuthLocalDatasource: TokenProviding {
private let keychain: any KeychainStorage
private let sessionKey: String

public init(keychain: any KeychainStorage, sessionKey: String = "auth.session") {
self.keychain = keychain
self.sessionKey = sessionKey
}

public func save(_ session: AuthSession) async throws {
try await keychain.save(session, forKey: sessionKey)
}

public func load() async throws -> AuthSession? {
try await keychain.get(forKey: sessionKey)
}

public func clear() async throws {
try await keychain.delete(forKey: sessionKey)
}

public func accessToken() async throws -> String? {
try await load()?.accessToken
}
}
43 changes: 43 additions & 0 deletions Projects/Data/Sources/Auth/Datasource/AuthRemoteDatasource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import CoreNetwork
import Foundation

public struct AuthRemoteDatasource: Sendable {
private let plainClient: any NetworkClient
private let authedClient: any NetworkClient

public init(
plainClient: any NetworkClient,
authedClient: any NetworkClient
) {
self.plainClient = plainClient
self.authedClient = authedClient
}

public func loginWithKakao(accessToken: String) async throws -> LoginResponseDTO {
let body = try makeEncoder().encode(KakaoLoginRequestDTO(accessToken: accessToken))
return try await plainClient.request(AuthEndpoint.loginKakao(body))
}

public func loginWithApple(identityToken: String) async throws -> LoginResponseDTO {
let body = try makeEncoder().encode(AppleLoginRequestDTO(identityToken: identityToken))
return try await plainClient.request(AuthEndpoint.loginApple(body))
}

public func loginWithDev() async throws -> LoginResponseDTO {
let body = try makeEncoder().encode(DevLoginRequestDTO())
return try await plainClient.request(AuthEndpoint.loginDev(body))
}

public func refresh(refreshToken: String) async throws -> TokenResponseDTO {
let body = try makeEncoder().encode(RefreshRequestDTO(refreshToken: refreshToken))
return try await plainClient.request(AuthEndpoint.refresh(body))
}

public func logout() async throws {
try await authedClient.request(AuthEndpoint.logout)
}
Comment thread
gnoes-ios marked this conversation as resolved.

private func makeEncoder() -> JSONEncoder {
NetworkJSONCoding.makeEncoder()
}
}
41 changes: 41 additions & 0 deletions Projects/Data/Sources/Auth/Endpoint/AuthEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import CoreNetwork
import Foundation

enum AuthEndpoint: APIEndpoint {
case loginKakao(Data)
case loginApple(Data)
case loginDev(Data)
case refresh(Data)
case logout

var path: String {
switch self {
case .loginKakao:
return "/api/auth/login/kakao"
case .loginApple:
return "/api/auth/login/apple"
case .loginDev:
return "/api/auth/login/dev"
case .refresh:
return "/api/auth/refresh"
case .logout:
return "/api/auth/logout"
}
}

var method: HTTPMethod {
.post
}

var body: Data? {
switch self {
case let .loginKakao(body),
let .loginApple(body),
let .loginDev(body),
let .refresh(body):
return body
case .logout:
return nil
}
}
}
Loading