import Foundation /// Real-network DSClient (FU-A). Same DTOs/decoder as FixtureDSClient, so swapping it in is /// behavior-identical except for I/O. URLSession with shared cookie storage so the HttpOnly refresh /// cookie is replayed on `/auth/refresh`. A 401 on a bearer request triggers a single-flight refresh /// + ONE retry (never on login/refresh/logout, to avoid loops). /// /// Immutable stored props (URLSession/decoder shared but never mutated) → @unchecked Sendable. public final class LiveDSClient: DSClient, @unchecked Sendable { private let base: DSBaseURL private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder private let tokens: TokenProvider public init(base: DSBaseURL = .publicTLS, persistence: TokenPersistence = InMemoryTokenStore()) { let baseURL = base let config = URLSessionConfiguration.default config.httpCookieStorage = .shared config.httpShouldSetCookies = true let session = URLSession(configuration: config) let decoder = DSDecoder.make() self.base = baseURL self.session = session self.decoder = decoder self.encoder = DSEncoder.make() // Refresh closure captures only Sendable values (no self): raw POST /auth/refresh via cookie. self.tokens = TokenProvider(persistence: persistence) { var request = URLRequest(url: baseURL.url.appendingPathComponent("auth/refresh")) request.httpMethod = "POST" let (data, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw DSError.unauthorized(message: "refresh failed") } return try decoder.decode(AccessTokenResponse.self, from: data).accessToken } } public func setAccessToken(_ token: String) async { await tokens.set(token) } /// realRouter 의 ask 토큰 closure 용 — TokenProvider 단일 소스 (401 refresh 회전 반영). public func currentAccessToken() async -> String? { await tokens.current() } // MARK: - Request building / sending private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest { // Build URL from the base string to preserve trailing slashes; URLComponents percent-encodes. let raw = base.url.absoluteString + "/" + endpoint.path guard var comps = URLComponents(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") } if !endpoint.queryItems.isEmpty { comps.queryItems = endpoint.queryItems } guard let url = comps.url else { throw DSError.transport(underlying: "bad URL components") } var request = URLRequest(url: url) request.httpMethod = endpoint.method if endpoint.requiresBearer, let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } if let body = try endpoint.httpBody(encoder) { request.httpBody = body request.setValue("application/json", forHTTPHeaderField: "Content-Type") } return request } private func perform(_ endpoint: DSEndpoint) async throws -> Data { try await performWithRetry(requiresBearer: endpoint.requiresBearer) { token in try self.makeRequest(endpoint, token: token) } } /// 401 단일-비행 refresh + 1회 재시도의 공용 경로. `build` 가 (현 토큰)→URLRequest 를 만들고, /// 401 이면 새 토큰으로 한 번 더 빌드해 재전송한다. JSON 경로(perform)와 멀티파트 업로드가 공유. private func performWithRetry( requiresBearer: Bool, _ build: (_ token: String?) throws -> URLRequest ) async throws -> Data { let request = try build(await tokens.current()) let (data, response) = try await dataOrTransport(request) guard let http = response as? HTTPURLResponse else { throw DSError.transport(underlying: "no HTTP response") } if http.statusCode == 401, requiresBearer { // Single-flight refresh + one retry. let newToken = try await tokens.refreshOnce() let retry = try build(newToken) let (data2, response2) = try await dataOrTransport(retry) guard let http2 = response2 as? HTTPURLResponse else { throw DSError.transport(underlying: "no HTTP response") } guard (200..<300).contains(http2.statusCode) else { throw DSError.from(status: http2.statusCode, data: data2) } return data2 } guard (200..<300).contains(http.statusCode) else { throw DSError.from(status: http.statusCode, data: data) } return data } private func dataOrTransport(_ request: URLRequest) async throws -> (Data, URLResponse) { do { return try await session.data(for: request) } catch { throw DSError.transport(underlying: "\(error)") } } private func send(_ endpoint: DSEndpoint, as type: T.Type) async throws -> T { let data = try await perform(endpoint) do { return try decoder.decode(T.self, from: data) } catch { throw DSError.decoding("\(endpoint.path): \(error)") } } private func sendVoid(_ endpoint: DSEndpoint) async throws { _ = try await perform(endpoint) } // MARK: - DSClient public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse { let token: AccessTokenResponse = try await send(.login(username, password, totpCode), as: AccessTokenResponse.self) await tokens.set(token.accessToken) return token } public func me() async throws -> UserResponse { try await send(.me, as: UserResponse.self) } public func refresh() async throws -> AccessTokenResponse { let token: AccessTokenResponse = try await send(.refresh, as: AccessTokenResponse.self) await tokens.set(token.accessToken) return token } public func logout() async throws { try await sendVoid(.logout); await tokens.clear() } public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try await send(.documents(query), as: DocumentListResponse.self) } public func document(id: Int) async throws -> DocumentDetailResponse { try await send(.document(id), as: DocumentDetailResponse.self) } public func documentContent(id: Int) async throws -> DocumentContentResponse { try await send(.documentContent(id), as: DocumentContentResponse.self) } public func documentTree() async throws -> [DomainTreeNode] { try await send(.documentTree, as: [DomainTreeNode].self) } public func categoryCounts() async throws -> CategoryCounts { try await send(.categoryCounts, as: CategoryCounts.self) } public func duplicates() async throws -> DuplicatesResponse { try await send(.duplicates, as: DuplicatesResponse.self) } public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await send(.patchDocument(id, update), as: DocumentResponse.self) } public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) } public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) } public func uploadDocument(_ upload: DocumentUpload) async throws -> DocumentResponse { let boundary = "DSBoundary-\(UUID().uuidString)" let body = LiveDSClient.multipartBody(for: upload, boundary: boundary) // 트레일링 슬래시 유지(POST /documents/) — base 문자열 결합 (appendingPathComponent 는 슬래시 strip). let raw = base.url.absoluteString + "/documents/" guard let url = URL(string: raw) else { throw DSError.transport(underlying: "bad URL \(raw)") } let data = try await performWithRetry(requiresBearer: true) { token in var request = URLRequest(url: url) request.httpMethod = "POST" if let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.httpBody = body return request } do { return try decoder.decode(DocumentResponse.self, from: data) } catch { throw DSError.decoding("documents/ upload: \(error)") } } /// multipart/form-data 본문 생성. file 파트 + 선택 form 필드(doc_purpose/library_path). /// internal(테스트 가시) — 한글 파일명은 UTF-8 바이트 그대로(Starlette 가 디코드). static func multipartBody(for upload: DocumentUpload, boundary: String) -> Data { var body = Data() func appendField(_ name: String, _ value: String) { body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".utf8)) body.append(Data("\(value)\r\n".utf8)) } if let p = upload.docPurpose { appendField("doc_purpose", p) } if let lp = upload.libraryPath { appendField("library_path", lp) } body.append(Data("--\(boundary)\r\n".utf8)) body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(upload.filename)\"\r\n".utf8)) body.append(Data("Content-Type: \(upload.mimeType ?? "application/octet-stream")\r\n\r\n".utf8)) body.append(upload.data) body.append(Data("\r\n".utf8)) body.append(Data("--\(boundary)--\r\n".utf8)) return body } public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) } public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) } public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await send(.memos(query), as: MemoListResponse.self) } public func memo(id: Int) async throws -> MemoResponse { try await send(.memo(id), as: MemoResponse.self) } public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await send(.createMemo(create), as: MemoResponse.self) } public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await send(.patchMemo(id, update), as: MemoResponse.self) } public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await send(.pinMemo(id, pinned), as: MemoResponse.self) } public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await send(.archiveMemo(id, archived), as: MemoResponse.self) } public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await send(.toggleMemoTask(id, taskIndex, checked), as: MemoResponse.self) } public func deleteMemo(id: Int) async throws { try await sendVoid(.deleteMemo(id)) } public func digest(date: String?, country: String?) async throws -> DigestResponse { try await send(.digest(date, country), as: DigestResponse.self) } }