Files
edgeever/apps/ios/EdgeEver/Data/Network/APIClient.swift
T

824 lines
33 KiB
Swift

import Foundation
actor APIClient {
private(set) var baseURL: URL
private(set) var token: String?
private var onUnauthorized: (@Sendable () -> Void)?
private let session: URLSession
init(
baseURL: URL,
token: String? = nil,
session: URLSession = .shared,
onUnauthorized: (@Sendable () -> Void)? = nil
) {
self.baseURL = baseURL.edgeEverNormalizedBase
self.token = token
self.session = session
self.onUnauthorized = onUnauthorized
}
func update(baseURL: URL, token: String?, onUnauthorized: (@Sendable () -> Void)? = nil) {
self.baseURL = baseURL.edgeEverNormalizedBase
self.token = token
if let onUnauthorized {
self.onUnauthorized = onUnauthorized
}
}
func setOnUnauthorized(_ handler: (@Sendable () -> Void)?) {
onUnauthorized = handler
}
// MARK: - Instance diagnostics
func getInstanceHealth() async throws -> InstanceHealth {
try await request(path: "/api/health")
}
func getInstanceRelease() async throws -> InstanceRelease {
try await request(path: "/api/release")
}
// MARK: - Auth
func login(_ input: LoginInput) async throws -> AuthSession {
try await request(path: "/api/v1/auth/login", method: "POST", body: input)
}
func logout() async throws {
let _: OkResponse = try await request(path: "/api/v1/auth/logout", method: "POST", body: EmptyBody())
}
func getSession() async throws -> AuthSession {
try await request(path: "/api/v1/auth/session")
}
func changePassword(current: String, newPassword: String, confirm: String) async throws {
struct Body: Encodable {
var currentPassword: String
var newPassword: String
var confirmPassword: String
}
let _: OkResponse = try await request(
path: "/api/v1/auth/change-password",
method: "POST",
body: Body(currentPassword: current, newPassword: newPassword, confirmPassword: confirm)
)
}
func listLoginDeviceSessions() async throws -> [LoginDeviceSession] {
let response: SessionsResponse = try await request(path: "/api/v1/auth/sessions")
return response.sessions
}
func revokeLoginDeviceSession(id: String) async throws {
let _: OkResponse = try await request(path: "/api/v1/auth/sessions/\(id)", method: "DELETE")
}
func revokeOtherLoginDeviceSessions() async throws {
let _: OkResponse = try await request(path: "/api/v1/auth/sessions", method: "DELETE")
}
// MARK: - Sync
func getMobileSyncBootstrapPage(afterId: String?, limit: Int = 200) async throws -> MobileSyncBootstrapPage {
var items: [URLQueryItem] = [.init(name: "limit", value: String(limit))]
if let afterId { items.append(.init(name: "afterId", value: afterId)) }
return try await request(path: "/api/v1/sync/bootstrap", query: items)
}
func getMobileSyncChanges(cursor: Int, limit: Int = 200) async throws -> MobileSyncChangesPage {
try await request(
path: "/api/v1/sync/changes",
query: [
.init(name: "cursor", value: String(cursor)),
.init(name: "limit", value: String(limit)),
]
)
}
// MARK: - Notebooks
func createNotebook(name: String, parentId: String?) async throws -> Notebook {
struct Body: Encodable {
var name: String
var parentId: String?
}
let response: NotebookResponse = try await request(
path: "/api/v1/notebooks",
method: "POST",
body: Body(name: name, parentId: parentId)
)
return response.notebook
}
// MARK: - Memos
func createMemo(
notebookId: String,
title: String?,
contentMarkdown: String?,
tags: [String]?,
createdAt: String? = nil,
updatedAt: String? = nil
) async throws -> MemoDetail {
struct Body: Encodable {
var notebookId: String
var title: String?
var contentMarkdown: String?
var tags: [String]?
var createdAt: String?
var updatedAt: String?
}
let response: MemoResponse = try await request(
path: "/api/v1/memos",
method: "POST",
body: Body(
notebookId: notebookId,
title: title,
contentMarkdown: contentMarkdown,
tags: tags,
createdAt: createdAt,
updatedAt: updatedAt
)
)
return response.memo
}
func updateMemo(
id: String,
expectedRevision: Int?,
expectedContentHash: String?,
editSessionId: String?,
notebookId: String?,
title: String?,
isPinned: Bool?,
contentMarkdown: String?,
contentJson: JSONValue? = nil,
tags: [String]?
) async throws -> MemoDetail {
struct Body: Encodable {
var expectedRevision: Int?
var expectedContentHash: String?
var editSessionId: String?
var notebookId: String?
var title: String?
var isPinned: Bool?
var contentMarkdown: String?
var contentJson: JSONValue?
var tags: [String]?
}
let response: MemoResponse = try await request(
path: "/api/v1/memos/\(id)",
method: "PATCH",
body: Body(
expectedRevision: expectedRevision,
expectedContentHash: expectedContentHash,
editSessionId: editSessionId,
notebookId: notebookId,
title: title,
isPinned: isPinned,
contentMarkdown: contentMarkdown,
contentJson: contentJson,
tags: tags
)
)
return response.memo
}
func getMemo(id: String, includeDeleted: Bool = false) async throws -> MemoDetail {
var query: [URLQueryItem] = []
if includeDeleted { query.append(.init(name: "includeDeleted", value: "1")) }
let response: MemoResponse = try await request(path: "/api/v1/memos/\(id)", query: query)
return response.memo
}
func deleteMemo(id: String, permanent: Bool = false) async throws {
var query: [URLQueryItem] = []
if permanent { query.append(.init(name: "permanent", value: "1")) }
let _: OkResponse = try await request(path: "/api/v1/memos/\(id)", method: "DELETE", query: query)
}
func createMemoEditSession(memoId: String) async throws -> MemoEditSession {
let response: EditSessionResponse = try await request(
path: "/api/v1/memos/\(memoId)/edit-sessions",
method: "POST",
body: EmptyBody()
)
return response.editSession
}
// MARK: - AI note processing
func listAiPrompts(locale: String) async throws -> [AiPromptTemplate] {
let response: AiPromptsResponse = try await request(
path: "/api/v1/ai/prompts",
query: [.init(name: "locale", value: locale)]
)
return response.prompts
}
func suggestAiTags(_ input: AiTagSuggestionsInput) async throws -> AiTagSuggestionsResponse {
try await request(
path: "/api/v1/ai/tag-suggestions",
method: "POST",
body: input
)
}
func streamAiGeneration(_ input: AiGenerateInput) -> AsyncThrowingStream<AiStreamEvent, Error> {
let session = self.session
let unauthorized = onUnauthorized
let sessionToken = token
let generateURL = makeURL(path: "/api/v1/ai/generate")
return AsyncThrowingStream { continuation in
let task = Task {
do {
do {
let prepared: AiPreparedGeneration = try await self.request(
path: "/api/v1/ai/generate/prepare",
method: "POST",
body: input
)
try await Self.streamDirectProvider(
prepared,
session: session,
continuation: continuation
)
} catch let apiError as APIError where apiError.status == 404 {
try await Self.streamEdgeEverGenerate(
input: input,
url: generateURL,
token: sessionToken,
session: session,
unauthorized: unauthorized,
continuation: continuation
)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
private static func streamDirectProvider(
_ prepared: AiPreparedGeneration,
session: URLSession,
continuation: AsyncThrowingStream<AiStreamEvent, Error>.Continuation
) async throws {
let request = try AiDirectStream.providerRequest(for: prepared)
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
guard (200 ..< 300).contains(http.statusCode) else {
var data = Data()
for try await byte in bytes { data.append(byte) }
let raw = String(data: data, encoding: .utf8) ?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
throw APIError(status: http.statusCode, code: "ai_generation_failed", message: String(raw.prefix(1000)))
}
continuation.yield(AiStreamEvent(type: "start", text: nil, code: nil, message: nil, finishReason: nil, inputTokens: nil, outputTokens: nil))
let normalizer = AiGenerationStreamNormalizer(resultBoundary: prepared.resultBoundary)
var hasContent = false
var finishReason: String?
var inputTokens: Int?
var outputTokens: Int?
for try await line in bytes.lines {
try Task.checkCancellation()
guard line.hasPrefix("data:") else { continue }
let payload = line.dropFirst(5).trimmingCharacters(in: .whitespaces)
if payload.isEmpty || payload == "[DONE]" { continue }
guard let data = payload.data(using: .utf8),
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
let delta = AiDirectStream.extractDelta(provider: prepared.provider, payload: json)
if let reason = delta.finishReason { finishReason = reason }
if let tokens = delta.inputTokens { inputTokens = tokens }
if let tokens = delta.outputTokens { outputTokens = tokens }
let text = normalizer.push(delta.text)
if text.isEmpty { continue }
hasContent = hasContent || !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
continuation.yield(AiStreamEvent(type: "text-delta", text: text, code: nil, message: nil, finishReason: nil, inputTokens: nil, outputTokens: nil))
}
let trailing = normalizer.finish()
if !trailing.isEmpty {
hasContent = hasContent || !trailing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
continuation.yield(AiStreamEvent(type: "text-delta", text: trailing, code: nil, message: nil, finishReason: nil, inputTokens: nil, outputTokens: nil))
}
if !hasContent {
continuation.yield(AiStreamEvent(type: "error", text: nil, code: "ai_generation_failed", message: "The AI did not return a note result.", finishReason: nil, inputTokens: nil, outputTokens: nil))
return
}
continuation.yield(AiStreamEvent(type: "finish", text: nil, code: nil, message: nil, finishReason: finishReason, inputTokens: inputTokens, outputTokens: outputTokens))
}
private static func streamEdgeEverGenerate(
input: AiGenerateInput,
url: URL,
token: String?,
session: URLSession,
unauthorized: (@Sendable () -> Void)?,
continuation: AsyncThrowingStream<AiStreamEvent, Error>.Continuation
) async throws {
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
request.httpBody = try EdgeEverJSON.encoder.encode(input)
let (bytes, response) = try await session.bytes(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
if http.statusCode == 401 {
unauthorized?()
}
guard (200 ..< 300).contains(http.statusCode) else {
var data = Data()
for try await byte in bytes { data.append(byte) }
let message = Self.parseErrorMessage(data: data)
?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
throw APIError(
status: http.statusCode,
code: Self.parseErrorCode(data: data),
message: message
)
}
for try await line in bytes.lines {
try Task.checkCancellation()
guard line.hasPrefix("data: ") else { continue }
let payload = String(line.dropFirst(6))
guard let data = payload.data(using: .utf8) else { continue }
continuation.yield(try EdgeEverJSON.decoder.decode(AiStreamEvent.self, from: data))
}
}
// MARK: - Templates
func listTemplates() async throws -> [MemoTemplate] {
let response: TemplatesResponse = try await request(path: "/api/v1/templates")
return response.templates
}
func useTemplate(templateId: String, notebookId: String) async throws -> MemoDetail {
struct Body: Encodable {
var notebookId: String
}
let response: MemoResponse = try await request(
path: "/api/v1/templates/\(templateId)/use",
method: "POST",
body: Body(notebookId: notebookId)
)
return response.memo
}
// MARK: - Resources / tokens / tags
func uploadMemoResource(memoId: String, filename: String, mimeType: String, data: Data) async throws -> Resource {
try await uploadMemoResourceParts(
memoId: memoId,
filename: filename,
mimeType: mimeType,
byteSize: data.count,
readPart: { range in data.subdata(in: range) }
)
}
/// Upload a file without retaining the complete attachment in process memory.
/// The file handle reads only the server-advertised multipart chunk for each request.
func uploadMemoResource(memoId: String, filename: String, mimeType: String, fileURL: URL) async throws -> Resource {
let values = try fileURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true, let byteSize = values.fileSize, byteSize > 0 else {
throw APIError(status: 0, code: nil, message: "Attachment file is unavailable")
}
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
return try await uploadMemoResourceParts(
memoId: memoId,
filename: filename,
mimeType: mimeType,
byteSize: byteSize,
readPart: { range in
try handle.seek(toOffset: UInt64(range.lowerBound))
let data = try handle.read(upToCount: range.count) ?? Data()
guard data.count == range.count else {
throw APIError(status: 0, code: nil, message: "Attachment file changed while uploading")
}
return data
}
)
}
private func uploadMemoResourceParts(
memoId: String,
filename: String,
mimeType: String,
byteSize: Int,
readPart: (Range<Int>) throws -> Data
) async throws -> Resource {
struct StartBody: Encodable {
var filename: String
var mimeType: String
var byteSize: Int
}
struct Upload: Decodable {
var id: String
var partSize: Int
var partCount: Int
}
struct StartResponse: Decodable { var upload: Upload }
let started: StartResponse = try await request(
path: "/api/v1/memos/\(memoId)/resource-uploads",
method: "POST",
body: StartBody(filename: filename, mimeType: mimeType, byteSize: byteSize)
)
do {
for partNumber in 1 ... started.upload.partCount {
let start = (partNumber - 1) * started.upload.partSize
let end = min(start + started.upload.partSize, byteSize)
let chunk = try readPart(start ..< end)
var attempt = 0
while true {
do {
var partRequest = URLRequest(
url: makeURL(path: "/api/v1/resource-uploads/\(started.upload.id)/parts/\(partNumber)")
)
partRequest.httpMethod = "PUT"
partRequest.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
partRequest.setValue(String(chunk.count), forHTTPHeaderField: "Content-Length")
if let token {
partRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
partRequest.httpBody = chunk
_ = try await performRaw(partRequest)
break
} catch {
attempt += 1
let retryable = !(error is APIError)
|| ((error as? APIError).map { $0.status == 408 || $0.status == 429 || $0.status >= 500 } ?? false)
if !retryable || attempt >= 3 { throw error }
try await Task.sleep(for: .milliseconds(attempt * 250))
}
}
}
let completed: ResourceResponse = try await request(
path: "/api/v1/resource-uploads/\(started.upload.id)/complete",
method: "POST",
body: EmptyBody()
)
return completed.resource
} catch {
let _: OkResponse? = try? await request(
path: "/api/v1/resource-uploads/\(started.upload.id)",
method: "DELETE"
)
throw error
}
}
/// Fetch a resource path (usually `/api/v1/resources/:id/blob`) with session auth.
func getResourceData(path: String) async throws -> (data: Data, mimeType: String) {
var request = URLRequest(url: makeURL(path: path))
request.httpMethod = "GET"
request.setValue("*/*", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
if http.statusCode == 401 {
onUnauthorized?()
}
guard (200 ..< 300).contains(http.statusCode) else {
throw APIError(status: http.statusCode, code: nil, message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode))
}
let header = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
let mimeType = header.split(separator: ";").first.map(String.init)?.trimmingCharacters(in: .whitespacesAndNewlines)
?? "application/octet-stream"
return (data, mimeType)
}
/// Download a protected resource directly to a temporary file without retaining
/// the complete response body in process memory.
func downloadResourceFile(path: String, suggestedFilename: String) async throws -> URL {
var request = URLRequest(url: makeURL(path: path))
request.httpMethod = "GET"
request.setValue("*/*", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
let (temporaryURL, response) = try await session.download(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
if http.statusCode == 401 {
onUnauthorized?()
}
guard (200 ..< 300).contains(http.statusCode) else {
throw APIError(
status: http.statusCode,
code: nil,
message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
)
}
let safeName = suggestedFilename
.replacingOccurrences(of: "/", with: "_")
.trimmingCharacters(in: .whitespacesAndNewlines)
let filename = safeName.isEmpty ? "resource.bin" : safeName
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("edgeever-share", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(filename)
if FileManager.default.fileExists(atPath: destination.path) {
try FileManager.default.removeItem(at: destination)
}
try FileManager.default.moveItem(at: temporaryURL, to: destination)
return destination
}
/// Fetch an absolute public URL and return bytes + mime (for file:// WebView display).
func getPublicURLData(_ absoluteURL: URL) async throws -> (data: Data, mimeType: String) {
var request = URLRequest(url: absoluteURL)
request.httpMethod = "GET"
request.setValue("*/*", forHTTPHeaderField: "Accept")
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
guard (200 ..< 300).contains(http.statusCode) else {
throw APIError(status: http.statusCode, code: nil, message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode))
}
let header = http.value(forHTTPHeaderField: "Content-Type") ?? "application/octet-stream"
let mimeType = header.split(separator: ";").first.map(String.init)?.trimmingCharacters(in: .whitespacesAndNewlines)
?? "application/octet-stream"
return (data, mimeType)
}
func renameResource(id: String, filename: String) async throws -> Resource {
struct Body: Encodable { var filename: String }
let response: ResourceResponse = try await request(
path: "/api/v1/resources/\(id)",
method: "PATCH",
body: Body(filename: filename)
)
return response.resource
}
func deleteResource(id: String) async throws {
let _: OkResponse = try await request(path: "/api/v1/resources/\(id)", method: "DELETE")
}
func listApiTokens() async throws -> ApiTokensResponse {
try await request(path: "/api/v1/api-tokens")
}
func createApiToken(name: String, scopes: [String]) async throws -> CreatedApiToken {
struct Body: Encodable {
var name: String
var scopes: [String]
}
return try await request(path: "/api/v1/api-tokens", method: "POST", body: Body(name: name, scopes: scopes))
}
func revokeApiToken(id: String) async throws {
let _: OkResponse = try await request(path: "/api/v1/api-tokens/\(id)", method: "DELETE")
}
func listTags() async throws -> [TagSummary] {
let response: TagsResponse = try await request(path: "/api/v1/tags")
return response.tags
}
func renameTag(tag: String, name: String) async throws {
struct Body: Encodable { var name: String }
let encoded = tag.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? tag
let _: OkResponse = try await request(
path: "/api/v1/tags/\(encoded)",
method: "PATCH",
body: Body(name: name)
)
}
func deleteTag(tag: String) async throws {
let encoded = tag.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? tag
let _: OkResponse = try await request(path: "/api/v1/tags/\(encoded)", method: "DELETE")
}
func listMemoRevisions(memoId: String) async throws -> [MemoRevision] {
struct Response: Codable { var revisions: [MemoRevision] }
let response: Response = try await request(path: "/api/v1/memos/\(memoId)/revisions")
return response.revisions
}
func restoreMemoRevision(memoId: String, revisionId: String) async throws -> MemoDetail {
let response: MemoResponse = try await request(
path: "/api/v1/memos/\(memoId)/revisions/\(revisionId)/restore",
method: "POST",
body: EmptyBody()
)
return response.memo
}
func getMemoShare(memoId: String) async throws -> MemoShare? {
struct Response: Codable { var share: MemoShare? }
let response: Response = try await request(path: "/api/v1/memos/\(memoId)/share")
return response.share
}
func createMemoShare(memoId: String) async throws -> MemoShare {
struct Response: Codable { var share: MemoShare }
let response: Response = try await request(
path: "/api/v1/memos/\(memoId)/share",
method: "POST",
body: EmptyBody()
)
return response.share
}
func revokeMemoShare(memoId: String) async throws {
let _: OkResponse = try await request(path: "/api/v1/memos/\(memoId)/share", method: "DELETE")
}
func listUsers() async throws -> [InstanceUser] {
struct Response: Codable { var users: [InstanceUser] }
let response: Response = try await request(path: "/api/v1/users")
return response.users
}
func createUser(username: String, displayName: String?, password: String) async throws -> InstanceUser {
struct Body: Encodable {
var username: String
var displayName: String?
var password: String
}
struct Response: Codable { var user: InstanceUser }
let response: Response = try await request(
path: "/api/v1/users",
method: "POST",
body: Body(username: username, displayName: displayName, password: password)
)
return response.user
}
func updateUser(userId: String, displayName: String?, password: String?, isDisabled: Bool?) async throws -> InstanceUser {
struct Body: Encodable {
var displayName: String?
var password: String?
var isDisabled: Bool?
}
struct Response: Codable { var user: InstanceUser }
let response: Response = try await request(
path: "/api/v1/users/\(userId)",
method: "PATCH",
body: Body(displayName: displayName, password: password, isDisabled: isDisabled)
)
return response.user
}
func deleteMemos(memoIds: [String], permanent: Bool = false) async throws {
struct Body: Encodable {
var memoIds: [String]
var permanent: Bool?
}
let _: OkResponse = try await request(
path: "/api/v1/memos/batch/delete",
method: "POST",
body: Body(memoIds: memoIds, permanent: permanent ? true : nil)
)
}
func moveMemos(memoIds: [String], notebookId: String) async throws {
struct Body: Encodable {
var memoIds: [String]
var notebookId: String
}
let _: OkResponse = try await request(
path: "/api/v1/memos/batch/move",
method: "POST",
body: Body(memoIds: memoIds, notebookId: notebookId)
)
}
// MARK: - Core request
private struct EmptyBody: Encodable {}
private func request<T: Decodable>(
path: String,
method: String = "GET",
query: [URLQueryItem] = [],
body: (any Encodable)? = nil
) async throws -> T {
var request = URLRequest(url: makeURL(path: path, query: query))
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try EdgeEverJSON.encoder.encode(AnyEncodable(body))
}
let data = try await performRaw(request)
return try EdgeEverJSON.decoder.decode(T.self, from: data)
}
private func perform(_ request: URLRequest) async throws -> ResourceResponse {
let data = try await performRaw(request)
return try EdgeEverJSON.decoder.decode(ResourceResponse.self, from: data)
}
private func performRaw(_ request: URLRequest) async throws -> Data {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else {
throw APIError(status: -1, code: nil, message: "Invalid response")
}
if http.statusCode == 401 {
onUnauthorized?()
}
guard (200 ..< 300).contains(http.statusCode) else {
let message = Self.parseErrorMessage(data: data) ?? HTTPURLResponse.localizedString(forStatusCode: http.statusCode)
let code = Self.parseErrorCode(data: data)
throw APIError(status: http.statusCode, code: code, message: message)
}
return data
}
private func makeURL(path: String, query: [URLQueryItem] = []) -> URL {
let base = baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let suffix = path.hasPrefix("/") ? path : "/\(path)"
guard var components = URLComponents(string: base + suffix) else {
preconditionFailure("Invalid URL for path \(path)")
}
if !query.isEmpty {
components.queryItems = query
}
guard let url = components.url else {
preconditionFailure("Invalid URL components for path \(path)")
}
return url
}
private static func parseErrorMessage(data: Data) -> String? {
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? [String: Any],
let message = error["message"] as? String
else { return nil }
return message
}
private static func parseErrorCode(data: Data) -> String? {
guard
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let error = json["error"] as? [String: Any],
let code = error["code"] as? String
else { return nil }
return code
}
}
struct APIError: Error, LocalizedError, Equatable, Sendable {
let status: Int
let code: String?
let message: String
var errorDescription: String? { message }
var isUnauthorized: Bool { status == 401 }
var isRevisionConflict: Bool { code == "revision_conflict" || status == 409 }
/// Server has no **memo** row for this id (update / edit-session).
/// Tight match only — never treat notebook/resource/workspace "not found" as memo 404.
var isMemoNotFound: Bool {
let lower = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
// API: `notFound(c, "Memo not found")` → code not_found, message "Memo not found".
let messageIsMemo = lower == "memo not found"
|| lower.hasPrefix("memo not found")
|| (lower.contains("memo") && lower.contains("not found")
&& !lower.contains("notebook")
&& !lower.contains("resource")
&& !lower.contains("revision")
&& !lower.contains("workspace"))
if status == 404, code == "not_found" || code == nil {
return messageIsMemo
}
return code == "not_found" && messageIsMemo
}
}
private struct AnyEncodable: Encodable {
private let encodeFunc: (Encoder) throws -> Void
init(_ value: any Encodable) { encodeFunc = value.encode }
func encode(to encoder: Encoder) throws { try encodeFunc(encoder) }
}