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
20 changes: 20 additions & 0 deletions Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Domain
import Foundation

public struct AlarmRefreshResponseDTO: Decodable, Sendable {
public let departureTime: String?
public let updatedAt: String?
public let lastRouteId: String?
// 서버가 Bool이 아니라 "true"/"false" 문자열로 준다 (레거시 실측).
public let isReal: String?

public func toEntity() -> AlarmInfo? {
guard let lastRouteId else { return nil }
return AlarmInfo(
lastRouteId: lastRouteId,
departureTime: departureTime.flatMap { ServerDateParser.date(from: $0) },
updatedAt: updatedAt.flatMap { ServerDateParser.date(from: $0) },
isReal: isReal == "true"
)
}
}
7 changes: 7 additions & 0 deletions Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
public struct AlarmRegisterRequestDTO: Encodable, Sendable {
public let lastRouteId: String

public init(lastRouteId: String) {
self.lastRouteId = lastRouteId
}
}
87 changes: 87 additions & 0 deletions Projects/Data/Sources/DTO/LastRouteResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Domain
import Foundation

public struct LastRouteResponseDTO: Decodable, Sendable {
public let routeId: String?
public let departureDateTime: String?
public let totalTime: Int?
public let totalWalkTime: Int?
public let transferCount: Int?
public let totalDistance: Int?
public let totalWalkDistance: Int?
public let legs: [LegResponseDTO]?

/// routeId·막차 출발 시각이 없는 항목은 세울 수 없어 nil을 돌려준다 (호출부 compactMap).
public func toEntity() -> LastRoute? {
guard let routeId,
let departureDateTime,
let departureTime = ServerDateParser.date(from: departureDateTime)
else { return nil }
return LastRoute(
id: routeId,
departureTime: departureTime,
totalTime: totalTime ?? 0,
totalWalkTime: totalWalkTime ?? 0,
transferCount: transferCount ?? 0,
totalDistance: totalDistance ?? 0,
totalWalkDistance: totalWalkDistance ?? 0,
legs: legs?.map { $0.toEntity() } ?? []
)
}
}

// 지도 표시 전용 필드(passStopList/step/passShape 등)는 2.0 스코프에 없어 디코딩하지 않는다.
public struct LegResponseDTO: Decodable, Sendable {
public let distance: Int?
public let sectionTime: Int?
// 레거시는 enum으로 받아 미지의 mode 문자열에서 디코딩이 통째로 실패했다 — String으로 받고 매핑한다.
public let mode: String?
public let departureDateTime: String?
public let route: String?
public let type: String?
public let start: RoutePointResponseDTO?
public let end: RoutePointResponseDTO?
public let subwayFinalStation: String?
public let subwayDirection: String?
public let isExpressSubway: Bool?
public let isLastSubway: Bool?

public func toEntity() -> TransportLeg {
TransportLeg(
mode: TransportMode(serverValue: mode),
sectionTime: sectionTime ?? 0,
distance: distance ?? 0,
departureTime: departureDateTime.flatMap { ServerDateParser.date(from: $0) },
routeName: route,
lineType: type,
start: start?.toEntity(),
end: end?.toEntity(),
subwayFinalStation: subwayFinalStation,
subwayDirection: subwayDirection,
isExpressSubway: isExpressSubway ?? false,
isLastSubway: isLastSubway ?? false
)
}
}

public struct RoutePointResponseDTO: Decodable, Sendable {
public let name: String?
public let lon: Double?
public let lat: Double?

public func toEntity() -> RoutePoint? {
guard let name, let lat, let lon else { return nil }
return RoutePoint(name: name, coordinate: Coordinate(latitude: lat, longitude: lon))
}
}

private extension TransportMode {
init(serverValue: String?) {
switch serverValue {
case "WALK": self = .walk
case "BUS": self = .bus
case "SUBWAY": self = .subway
default: self = .unknown
}
}
}
16 changes: 16 additions & 0 deletions Projects/Data/Sources/DTO/PlaceResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Domain

public struct PlaceResponseDTO: Decodable, Sendable {
public let name: String?
public let lat: Double?
public let lon: Double?
public let businessCategory: String?
public let address: String?
public let radius: String?

/// 레거시는 6필드 전부 non-nil이어야 항목을 살렸지만, 이름·좌표만 있으면 표시엔 충분하다.
public func toEntity() -> Place? {
guard let name, let lat, let lon else { return nil }
return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon))
}
}
13 changes: 13 additions & 0 deletions Projects/Data/Sources/DTO/ReverseGeocodeResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Domain

public struct ReverseGeocodeResponseDTO: Decodable, Sendable {
public let name: String?
public let address: String?
public let lat: Double?
public let lon: Double?

public func toEntity() -> Place? {
guard let name, let lat, let lon else { return nil }
return Place(name: name, address: address ?? "", coordinate: Coordinate(latitude: lat, longitude: lon))
}
}
17 changes: 17 additions & 0 deletions Projects/Data/Sources/Network/APIResponse.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// 레거시 서버 envelope 실측: { "responseCode": "SUCCESS", "result": ... }
struct APIResponse<T: Decodable & Sendable>: Decodable, Sendable {
let responseCode: String
let result: T?
}

/// result가 없는(무시되는) 응답을 기대할 때 쓰는 자리표시 타입.
struct APIEmptyResult: Decodable, Sendable {}

/// 실패 응답 본문 실측: { "responseCode": ..., "message": ..., "path": ... }
struct APIFailureResponse: Decodable, Sendable {
let responseCode: String?
let message: String?
}

/// envelope는 성공인데 기대한 result가 없거나 엔티티로 세울 수 없을 때.
struct MissingResultError: Error, Sendable {}
46 changes: 46 additions & 0 deletions Projects/Data/Sources/Network/AlarmEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CoreNetwork
import Foundation

enum AlarmEndpoint: Endpoint {
case register(AlarmRegisterRequestDTO)
case cancel(lastRouteId: String)
case refresh

var path: String {
switch self {
case .register, .cancel: "/routes/user-routes"
case .refresh: "/routes/user-routes/refresh"
}
}

var method: HTTPMethod {
switch self {
case .register: .post
case .cancel: .delete
case .refresh: .get
}
}

var headers: [String: String] {
switch self {
case .register: ["Content-Type": "application/json"]
case .cancel, .refresh: [:]
}
}

var queryItems: [URLQueryItem] {
switch self {
// 삭제는 body가 아니라 쿼리로 lastRouteId를 받는다 (레거시 실측).
case let .cancel(lastRouteId):
[URLQueryItem(name: "lastRouteId", value: lastRouteId)]
case .register, .refresh: []
}
}

var body: Data? {
switch self {
case let .register(request): try? JSONEncoder().encode(request)
case .cancel, .refresh: nil
}
}
}
46 changes: 46 additions & 0 deletions Projects/Data/Sources/Network/NetworkClient+Envelope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CoreNetwork
import Domain
import Foundation

private let successResponseCode = "SUCCESS"

extension NetworkClient {
/// envelope를 해체해 result만 돌려준다. responseCode ≠ SUCCESS면 `ServerError`.
func requestEnveloped<T: Decodable & Sendable>(
_ endpoint: any Endpoint,
as _: T.Type = T.self
) async throws -> T {
let data: Data
do {
data = try await self.data(for: endpoint)
} catch let error as NetworkError {
// 비즈니스 에러 코드는 non-2xx HTTP의 body envelope로 온다 (레거시 실측).
throw serverError(from: error) ?? error
}

let envelope: APIResponse<T>
do {
envelope = try JSONDecoder().decode(APIResponse<T>.self, from: data)
} catch {
// 레거시 규약: 2xx + 빈 응답 기대(T == APIEmptyResult)면 본문 형태와 무관하게 성공.
if let empty = APIEmptyResult() as? T { return empty }
throw NetworkError.decoding(underlying: error)
}

guard envelope.responseCode == successResponseCode else {
let message = (try? JSONDecoder().decode(APIFailureResponse.self, from: data))?.message
throw ServerError(code: envelope.responseCode, message: message)
}
if let result = envelope.result { return result }
if let empty = APIEmptyResult() as? T { return empty }
throw NetworkError.decoding(underlying: MissingResultError())
}
}

private func serverError(from error: NetworkError) -> ServerError? {
guard case let .unacceptableStatus(_, data) = error,
let failure = try? JSONDecoder().decode(APIFailureResponse.self, from: data),
let code = failure.responseCode
else { return nil }
return ServerError(code: code, message: failure.message)
}
38 changes: 38 additions & 0 deletions Projects/Data/Sources/Network/PlaceEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import CoreNetwork
import Domain
import Foundation

enum PlaceEndpoint: Endpoint {
case search(keyword: String, near: Coordinate?)
case reverseGeocode(Coordinate)

var path: String {
switch self {
case .search: "/locations"
case .reverseGeocode: "/locations/rgeo"
}
}

var method: HTTPMethod {
switch self {
case .search, .reverseGeocode: .get
}
}

var queryItems: [URLQueryItem] {
switch self {
case let .search(keyword, near):
// 좌표 미지정 시 0.0 전송은 레거시 실측 규약.
[
URLQueryItem(name: "keyword", value: keyword),
URLQueryItem(name: "lat", value: String(near?.latitude ?? 0.0)),
URLQueryItem(name: "lon", value: String(near?.longitude ?? 0.0)),
]
case let .reverseGeocode(coordinate):
[
URLQueryItem(name: "lat", value: String(coordinate.latitude)),
URLQueryItem(name: "lon", value: String(coordinate.longitude)),
]
}
}
}
34 changes: 34 additions & 0 deletions Projects/Data/Sources/Network/RouteEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import CoreNetwork
import Domain
import Foundation

enum RouteEndpoint: Endpoint {
case search(start: Coordinate, end: Coordinate)
case detail(routeId: String)

var path: String {
switch self {
case .search: "/routes/last-routes"
case let .detail(routeId): "/routes/last-routes/\(routeId)"
}
}

var method: HTTPMethod {
switch self {
case .search, .detail: .get
}
}

var queryItems: [URLQueryItem] {
switch self {
case let .search(start, end):
[
URLQueryItem(name: "startLat", value: String(start.latitude)),
URLQueryItem(name: "startLon", value: String(start.longitude)),
URLQueryItem(name: "endLat", value: String(end.latitude)),
URLQueryItem(name: "endLon", value: String(end.longitude)),
]
case .detail: []
}
}
}
18 changes: 18 additions & 0 deletions Projects/Data/Sources/Network/ServerDateParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// 서버 시각은 타임존 표기 없는 KST 문자열이다 (실측: "yyyy-MM-dd'T'HH:mm:ss").
enum ServerDateParser {
static func date(from string: String) -> Date? {
// DateFormatter는 Sendable이 아니므로 호출마다 새로 만든다.
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "Asia/Seoul")
for format in ["yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm"] {
formatter.dateFormat = format
if let date = formatter.date(from: string) {
return date
}
}
return nil
}
}
30 changes: 30 additions & 0 deletions Projects/Data/Sources/Repositories/AlarmRepositoryImpl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import CoreNetwork
import Domain

public struct AlarmRepositoryImpl: AlarmRepository {
private let networkClient: any NetworkClient

public init(networkClient: any NetworkClient) {
self.networkClient = networkClient
}

public func register(lastRouteId: String) async throws {
let _: APIEmptyResult = try await networkClient.requestEnveloped(
AlarmEndpoint.register(AlarmRegisterRequestDTO(lastRouteId: lastRouteId))
)
}

public func cancel(lastRouteId: String) async throws {
let _: APIEmptyResult = try await networkClient.requestEnveloped(
AlarmEndpoint.cancel(lastRouteId: lastRouteId)
)
}

public func refresh() async throws -> AlarmInfo {
let dto: AlarmRefreshResponseDTO = try await networkClient.requestEnveloped(AlarmEndpoint.refresh)
guard let info = dto.toEntity() else {
throw NetworkError.decoding(underlying: MissingResultError())
}
return info
}
}
Loading
Loading