feat(s3): LiveDSClient + Endpoint + Keychain/TokenProvider (FU-A plumbing)

- DSEndpoint: method/path/query/body single source (trailing slashes preserved, nil query skipped)
- KeychainStore + InMemoryTokenStore (TokenPersistence); TokenProvider actor with single-flight refresh (Task handle, cleared on completion)
- LiveDSClient: URLSession + shared cookie storage, Bearer injection, 401 -> single-flight refresh -> one retry (never on login/refresh/logout); same DTOs/decoder as fixtures
- Tests: endpoint path/method/query/body + single-flight (fires once) + token cache/persist

swift build + swift test green (25). Live HTTP path itself is FU-A (needs real backend). Sources/AI untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi
2026-06-05 06:38:07 +09:00
parent 560efb9554
commit 3520c8f82a
5 changed files with 466 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
import Foundation
import Security
/// Access-token persistence. The scaffold/preview/unsigned-build path uses InMemoryTokenStore
/// (Keychain on an unsigned binary can prompt or return errSecMissingEntitlement); the signed app
/// uses KeychainStore. Only the ACCESS token is stored refresh rides the HttpOnly cookie.
public protocol TokenPersistence: Sendable {
func read() -> String?
func save(_ token: String) throws
func delete() throws
}
public final class InMemoryTokenStore: TokenPersistence, @unchecked Sendable {
private let lock = NSLock()
private var token: String?
public init() {}
public func read() -> String? { lock.withLock { token } }
public func save(_ token: String) throws { lock.withLock { self.token = token } }
public func delete() throws { lock.withLock { token = nil } }
}
public struct KeychainStore: TokenPersistence, Sendable {
private let service: String
private let account: String
public init(service: String = "net.hyungi.ds-app", account: String = "access_token") {
self.service = service
self.account = account
}
public func read() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data,
let token = String(data: data, encoding: .utf8)
else { return nil }
return token
}
public func save(_ token: String) throws {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(base as CFDictionary) // upsert
var add = base
add[kSecValueData as String] = Data(token.utf8)
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess else {
throw DSError.transport(underlying: "keychain save failed (\(status))")
}
}
public func delete() throws {
let base: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
SecItemDelete(base as CFDictionary)
}
}
+43
View File
@@ -0,0 +1,43 @@
import Foundation
/// Concurrency-safe access-token holder with SINGLE-FLIGHT refresh: concurrent 401s must not each
/// fire `/auth/refresh`. The coalescing is via a stored `Task` handle (not a bool) entrants await
/// the same in-flight task; it's cleared (success OR failure) when complete. Clearing too early lets a
/// follower re-refresh; never clearing means the next expiry won't refresh.
public actor TokenProvider {
private var cached: String?
private let persistence: TokenPersistence
private let refresh: @Sendable () async throws -> String
private var inFlightRefresh: Task<String, Error>?
public init(persistence: TokenPersistence, refresh: @escaping @Sendable () async throws -> String) {
self.persistence = persistence
self.refresh = refresh
self.cached = persistence.read()
}
public func current() -> String? { cached ?? persistence.read() }
public func set(_ token: String) {
cached = token
try? persistence.save(token)
}
public func clear() {
cached = nil
try? persistence.delete()
}
public func refreshOnce() async throws -> String {
if let task = inFlightRefresh {
return try await task.value
}
let task = Task { try await self.refresh() }
inFlightRefresh = task
defer { inFlightRefresh = nil }
let token = try await task.value
cached = token
try? persistence.save(token)
return token
}
}
+135
View File
@@ -0,0 +1,135 @@
import Foundation
/// Single source of truth mapping each DSClient call to HTTP method + path + query + body.
/// Trailing slashes are significant (e.g. `documents/`, `search/`) and are preserved by building the
/// URL from the base string rather than appendingPathComponent (which strips them).
enum DSEndpoint {
case login(String, String, String?)
case me
case refresh
case logout
case documents(DocumentListQuery)
case document(Int)
case documentContent(Int)
case documentTree
case categoryCounts
case duplicates
case patchDocument(Int, DocumentUpdate)
case putContent(Int, String)
case deleteDocument(Int)
case search(String, SearchMode?, Int?, Bool?)
case ask(String, Int?, String?, Bool?)
case memos(MemoListQuery)
case memo(Int)
case createMemo(MemoCreate)
case patchMemo(Int, MemoUpdate)
case pinMemo(Int, Bool)
case archiveMemo(Int, Bool)
case toggleMemoTask(Int, Int, Bool)
case deleteMemo(Int)
case digest(String?, String?)
var method: String {
switch self {
case .login, .refresh, .logout, .createMemo: return "POST"
case .patchDocument, .patchMemo, .pinMemo, .archiveMemo, .toggleMemoTask: return "PATCH"
case .putContent: return "PUT"
case .deleteDocument, .deleteMemo: return "DELETE"
default: return "GET"
}
}
var path: String {
switch self {
case .login: return "auth/login"
case .me: return "auth/me"
case .refresh: return "auth/refresh"
case .logout: return "auth/logout"
case .documents: return "documents/"
case .document(let id): return "documents/\(id)"
case .documentContent(let id): return "documents/\(id)/content"
case .documentTree: return "documents/tree"
case .categoryCounts: return "documents/stats/category-counts"
case .duplicates: return "documents/duplicates"
case .patchDocument(let id, _): return "documents/\(id)"
case .putContent(let id, _): return "documents/\(id)/content"
case .deleteDocument(let id): return "documents/\(id)"
case .search: return "search/"
case .ask: return "search/ask"
case .memos: return "memos/"
case .memo(let id): return "memos/\(id)"
case .createMemo: return "memos/"
case .patchMemo(let id, _): return "memos/\(id)"
case .pinMemo(let id, _): return "memos/\(id)/pin"
case .archiveMemo(let id, _): return "memos/\(id)/archive"
case .toggleMemoTask(let id, let idx, _): return "memos/\(id)/tasks/\(idx)"
case .deleteMemo(let id): return "memos/\(id)"
case .digest: return "digest"
}
}
/// Bearer header applies to everything except login/refresh (refresh rides the HttpOnly cookie).
var requiresBearer: Bool {
switch self {
case .login, .refresh: return false
default: return true
}
}
var queryItems: [URLQueryItem] {
var items: [URLQueryItem] = []
func add(_ name: String, _ value: String?) { if let value { items.append(URLQueryItem(name: name, value: value)) } }
switch self {
case .documents(let q):
add("page", String(q.page)); add("page_size", String(q.pageSize))
add("domain", q.domain); add("sub_group", q.subGroup); add("source", q.source)
add("format", q.format); add("review_status", q.reviewStatus); add("category", q.category)
case .search(let qq, let mode, let page, let debug):
add("q", qq); add("mode", mode?.rawValue); add("page", page.map(String.init)); add("debug", debug.map(String.init))
case .ask(let qq, let limit, let backend, let debug):
add("q", qq); add("limit", limit.map(String.init)); add("backend", backend); add("debug", debug.map(String.init))
case .memos(let q):
add("page", String(q.page)); add("page_size", String(q.pageSize))
add("pinned", q.pinned.map(String.init)); add("archived", q.archived.map(String.init))
case .digest(let date, let country):
add("date", date); add("country", country)
default:
break
}
return items
}
func httpBody(_ encoder: JSONEncoder) throws -> Data? {
switch self {
case .login(let u, let p, let totp):
return try encoder.encode(LoginBody(username: u, password: p, totpCode: totp))
case .patchDocument(_, let update):
return try encoder.encode(update)
case .putContent(_, let content):
return try encoder.encode(ContentBody(content: content))
case .createMemo(let create):
return try encoder.encode(create)
case .patchMemo(_, let update):
return try encoder.encode(update)
case .pinMemo(_, let pinned):
return try encoder.encode(PinnedBody(pinned: pinned))
case .archiveMemo(_, let archived):
return try encoder.encode(ArchivedBody(archived: archived))
case .toggleMemoTask(_, _, let checked):
return try encoder.encode(CheckedBody(checked: checked))
default:
return nil
}
}
}
private struct LoginBody: Encodable {
let username: String
let password: String
let totpCode: String?
enum CodingKeys: String, CodingKey { case username, password; case totpCode = "totp_code" }
}
private struct ContentBody: Encodable { let content: String }
private struct PinnedBody: Encodable { let pinned: Bool }
private struct ArchivedBody: Encodable { let archived: Bool }
private struct CheckedBody: Encodable { let checked: Bool }
+135
View File
@@ -0,0 +1,135 @@
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) }
// 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 {
let request = try makeRequest(endpoint, token: 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, endpoint.requiresBearer {
// Single-flight refresh + one retry.
let newToken = try await tokens.refreshOnce()
let retry = try makeRequest(endpoint, token: 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<T: Decodable>(_ 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 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) }
}
@@ -0,0 +1,82 @@
import XCTest
@testable import DSKit
/// Offline tests for the live-client plumbing (the live HTTP path itself needs a real backend, FU-A).
final class ClientPlumbingTests: XCTestCase {
func testEndpointPathsAndMethods() {
XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).path, "documents/")
XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).method, "GET")
XCTAssertEqual(DSEndpoint.document(4912).path, "documents/4912")
XCTAssertEqual(DSEndpoint.documentTree.path, "documents/tree")
XCTAssertEqual(DSEndpoint.ask("q", nil, nil, nil).path, "search/ask")
XCTAssertEqual(DSEndpoint.pinMemo(5, true).method, "PATCH")
XCTAssertEqual(DSEndpoint.pinMemo(5, true).path, "memos/5/pin")
XCTAssertEqual(DSEndpoint.toggleMemoTask(5, 2, true).path, "memos/5/tasks/2")
XCTAssertEqual(DSEndpoint.putContent(7, "x").method, "PUT")
XCTAssertEqual(DSEndpoint.deleteMemo(9).method, "DELETE")
}
func testEndpointBearerRule() {
XCTAssertFalse(DSEndpoint.login("u", "p", nil).requiresBearer)
XCTAssertFalse(DSEndpoint.refresh.requiresBearer)
XCTAssertTrue(DSEndpoint.me.requiresBearer)
XCTAssertTrue(DSEndpoint.documents(DocumentListQuery()).requiresBearer)
}
func testEndpointQueryItems() {
let items = DSEndpoint.ask("충격시험", 5, "gemma-macmini", nil).queryItems
let dict = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value) })
XCTAssertEqual(dict["q"], "충격시험")
XCTAssertEqual(dict["backend"], "gemma-macmini")
XCTAssertEqual(dict["limit"], "5")
XCTAssertNil(dict["debug"]) // nil params are skipped, not sent as "nil"
}
func testEndpointBodies() throws {
let encoder = DSEncoder.make()
let pinBody = try DSEndpoint.pinMemo(5, true).httpBody(encoder)
XCTAssertEqual(String(data: pinBody ?? Data(), encoding: .utf8), #"{"pinned":true}"#)
let loginBody = try XCTUnwrap(DSEndpoint.login("hyungi", "pw", nil).httpBody(encoder))
let s = String(data: loginBody, encoding: .utf8) ?? ""
XCTAssertTrue(s.contains("\"username\":\"hyungi\""))
XCTAssertFalse(s.contains("totp_code")) // nil optional omitted
XCTAssertNil(try DSEndpoint.me.httpBody(encoder)) // GET has no body
}
/// Single-flight refresh: N concurrent refreshOnce() calls must fire the refresh closure exactly once.
func testSingleFlightRefresh() async throws {
let counter = CallCounter()
let provider = TokenProvider(persistence: InMemoryTokenStore()) {
try? await Task.sleep(nanoseconds: 50_000_000)
let n = await counter.inc()
return "token-\(n)"
}
let results = await withTaskGroup(of: String.self) { group -> [String] in
for _ in 0..<8 { group.addTask { (try? await provider.refreshOnce()) ?? "ERR" } }
var collected: [String] = []
for await value in group { collected.append(value) }
return collected
}
let calls = await counter.value
XCTAssertEqual(calls, 1, "refresh closure must run exactly once under concurrency")
XCTAssertTrue(results.allSatisfy { $0 == "token-1" })
}
func testTokenProviderCacheAndPersistence() async {
let store = InMemoryTokenStore()
let provider = TokenProvider(persistence: store) { "x" }
await provider.set("abc")
let current = await provider.current()
XCTAssertEqual(current, "abc")
XCTAssertEqual(store.read(), "abc")
await provider.clear()
let cleared = await provider.current()
XCTAssertNil(cleared)
}
}
actor CallCounter {
var value = 0
func inc() -> Int { value += 1; return value }
}