3520c8f82a
- 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>
136 lines
5.6 KiB
Swift
136 lines
5.6 KiB
Swift
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 }
|