diff --git a/CLAUDE.md b/CLAUDE.md index 60bf51d..3744ca1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,12 @@ Guidance for Claude Code in this repo. Follow it exactly — it overrides defaul - `decoder` — `convertFromSnakeCase`, all responses. - `encoder` (`convertToSnakeCase`) via `post`/`put`/`patch` — snake_case bodies. - `camelCaseEncoder` (plain) via `postCamel`/`putCamel`/`patchCamel` — the **many** camelCase endpoints (messages, lists, orgs, watchers, identities, change-email, …). Check the existing method before adding one. +- **New endpoints go in `APIClient+.swift`, not in `APIClient.swift`.** The HTTP seam + (`get`/`post…`/`delete…`/`postMultipartRawData`/`checkResponse` + `baseURL`, `session`, the three + coders) lives in `APIClientTransport.swift` and is deliberately **`internal`, not `private`** — + Swift's `private` is file-scoped, so a private helper would be invisible to an extension in + another file. That's what makes per-feature extension files possible; keep it that way, and don't + append to the `APIClient.swift` class body when a feature file will do. - **401 ≠ logged out.** Some endpoints only accept session cookies and reject a valid Bearer. `APIClient` throws `APIError.status(401)`; views call `authState.handleUnauthorized()`, which re-validates `GET /api/user` and only logs out if *that* 401s. Never `logout()` on a feature-endpoint 401. - **Token storage: Keychain only** (`KeychainService`), never `UserDefaults`. Deep-link token query items are secrets — never log them. - **Adding a `.swift` file:** no synced groups — register it in `project.pbxproj` (the `xcodeproj` Ruby gem) or it won't compile into the target. diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 46f0b6c..eafc1fb 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ 46E2E2E80CBE246E84A5D78B /* MarkdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3DF89EFAF63A9287656938C /* MarkdownView.swift */; }; 47FBAC21F771B2813EAD8D37 /* Moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1147B978D9E72695E4457454 /* Moderation.swift */; }; 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */; }; + 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 256FE9818A171E11DDE43140 /* APIClientTransport.swift */; }; 55457DA5473AFDA9C7764EBF /* DocumentSyncConflict.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */; }; 56CC87DAB10F3920C4CB2939 /* APIClientSharedResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7C7EF2962D974A135EE534 /* APIClientSharedResolverTests.swift */; }; 5703F883D4E6186DB66E5833 /* NotificationPreference.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA57E5D72AA1429A40660BA /* NotificationPreference.swift */; }; @@ -204,6 +205,7 @@ 1D4AE47FA0D5B0B2F713F5AA /* MutedUsersView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MutedUsersView.swift; sourceTree = ""; }; 229B4152945DABCD6D88A59A /* Manrope.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/Manrope.ttf; sourceTree = SOURCE_ROOT; }; 236670B73F2B603E5EB318D7 /* ShareInvite.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ShareInvite.swift; sourceTree = ""; }; + 256FE9818A171E11DDE43140 /* APIClientTransport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = APIClientTransport.swift; sourceTree = ""; }; 29AF87C767D57BD267ED0802 /* ComposeImageStrip.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ComposeImageStrip.swift; sourceTree = ""; }; 29DE4C26BE3C61875D921615 /* LinkedInPostingTarget.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LinkedInPostingTarget.swift; sourceTree = ""; }; 2A70E64AE3EB1E11EB9B7F83 /* PublicListDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PublicListDetailView.swift; sourceTree = ""; }; @@ -470,6 +472,7 @@ 5CF8FDEBF56E4CAB421FD811 /* DocumentSyncOutbox.swift */, 3F99C1D56FFD08CBD36F2273 /* NetworkReachability.swift */, BF02BD0C21F45BB2CFF34D67 /* DocumentSyncConflict.swift */, + 256FE9818A171E11DDE43140 /* APIClientTransport.swift */, ); path = Services; sourceTree = ""; @@ -828,6 +831,7 @@ 081D31E74FF9214FF1F7F185 /* SharedDocumentView.swift in Sources */, D63C6301A24D618B74B1FAC6 /* SharedListView.swift in Sources */, 7DC9B83A3D2C66E1F6F0AEC3 /* SharingView.swift in Sources */, + 4C5C8544DD7D81FA5C83A967 /* APIClientTransport.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 1556fa3..70d9769 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -4,9 +4,6 @@ // import Foundation -import os.log - -private let apiLog = Logger(subsystem: "com.interlinedlist.app", category: "APIClient") enum APIError: Error { case invalidURL @@ -37,24 +34,29 @@ enum ExportType: String, CaseIterable { final class APIClient { static let shared = APIClient() - private let baseURL: String - private let session: URLSessionProtocol + + // The five members below are read by `APIClientTransport.swift` and so are + // `internal`, not `private` — Swift's `private` is file-scoped and would hide + // them from the transport extension (and from every per-feature + // `APIClient+.swift`). All are `let`, so the seam stays read-only. + let baseURL: String + let session: URLSessionProtocol private(set) var bearerToken: String? - private let decoder: JSONDecoder = { + let decoder: JSONDecoder = { let d = JSONDecoder() d.keyDecodingStrategy = .convertFromSnakeCase return d }() - private let encoder: JSONEncoder = { + let encoder: JSONEncoder = { let e = JSONEncoder() e.keyEncodingStrategy = .convertToSnakeCase return e }() /// Encoder that keeps camelCase keys (for APIs that expect camelCase in the request body, e.g. POST /api/messages). - private let camelCaseEncoder: JSONEncoder = { + let camelCaseEncoder: JSONEncoder = { let e = JSONEncoder() return e }() @@ -169,15 +171,7 @@ final class APIClient { func unlinkIdentity(provider: String, providerId: String) async throws { struct Body: Encodable { let provider: String; let providerId: String } - guard let url = URL(string: baseURL + "/api/user/identities") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(Body(provider: provider, providerId: providerId)) - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await deleteCamel("/api/user/identities", body: Body(provider: provider, providerId: providerId)) } func verifyIdentity(provider: String, providerId: String) async throws { @@ -213,22 +207,10 @@ final class APIClient { // MARK: - Avatar upload (Phase 3 — sister agent dependency) func uploadAvatar(data: Data, mimeType: String) async throws -> User { - guard let url = URL(string: baseURL + "/api/user/avatar/upload") else { throw APIError.invalidURL } - let boundary = UUID().uuidString - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let ext = mimeType == "image/png" ? "png" : "jpg" - var body = Data() - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"avatar.\(ext)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - body.append(data) - body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - let (responseData, response) = try await session.data(for: request) - try checkResponse(data: responseData, response: response) + let responseData = try await postMultipartRawData( + "/api/user/avatar/upload", fileName: "avatar.\(ext)", mimeType: mimeType, fileData: data + ) struct UploadResp: Decodable { let url: String? } if let avatarUrl = (try? decoder.decode(UploadResp.self, from: responseData))?.url { return try await applyAvatarUrl(avatarUrl) @@ -409,14 +391,7 @@ final class APIClient { func undig(messageId: String) async throws -> DigResponse { let encoded = messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? messageId - guard let url = URL(string: baseURL + "/api/messages/\(encoded)/dig") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) - return try decoder.decode(DigResponse.self, from: data) + return try await deleteDecoding("/api/messages/\(encoded)/dig") } func replies(messageId: String, limit: Int = 50, offset: Int = 0) async throws -> [Message] { @@ -498,13 +473,7 @@ final class APIClient { func deleteList(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/lists/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/lists/\(encoded)") } func updateRow(listId: String, itemId: String, key: String, value: JSONValue) async throws -> ListItem { @@ -540,13 +509,7 @@ final class APIClient { func deleteListItem(listId: String, itemId: String) async throws { let encodedList = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId let encodedItem = itemId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? itemId - guard let url = URL(string: baseURL + "/api/lists/\(encodedList)/data/\(encodedItem)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/lists/\(encodedList)/data/\(encodedItem)") } // MARK: - Documents @@ -642,13 +605,7 @@ final class APIClient { func deleteDocument(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/documents/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/documents/\(encoded)") } func documentFolders() async throws -> [DocumentFolder] { @@ -670,13 +627,7 @@ final class APIClient { /// subfolders and documents inside it (`DELETE /api/documents/folders/{id}`). func deleteDocumentFolder(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/documents/folders/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/documents/folders/\(encoded)") } func searchDocuments(q: String, limit: Int = 20, offset: Int = 0) async throws -> ([Document], Pagination?) { @@ -789,22 +740,10 @@ final class APIClient { // MARK: - Image upload func uploadImage(data: Data, mimeType: String) async throws -> String { - guard let url = URL(string: baseURL + "/api/messages/images/upload") else { throw APIError.invalidURL } - let boundary = UUID().uuidString - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - var body = Data() let ext = mimeType == "image/png" ? "png" : "jpg" - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - body.append(data) - body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - let (responseData, response) = try await session.data(for: request) - try checkResponse(data: responseData, response: response) + let responseData = try await postMultipartRawData( + "/api/messages/images/upload", fileName: "upload.\(ext)", mimeType: mimeType, fileData: data + ) struct UploadResponse: Decodable { let url: String } return try decoder.decode(UploadResponse.self, from: responseData).url } @@ -812,23 +751,11 @@ final class APIClient { // MARK: - Document image upload func uploadDocumentImage(documentId: String, data: Data, mimeType: String) async throws -> String { - let encoded = documentId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? documentId - guard let url = URL(string: baseURL + "/api/documents/\(encoded)/images/upload") else { throw APIError.invalidURL } - let boundary = UUID().uuidString - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let ext = mimeType == "image/png" ? "png" : "jpg" - var body = Data() - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - body.append(data) - body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - let (responseData, response) = try await session.data(for: request) - try checkResponse(data: responseData, response: response) + let responseData = try await postMultipartRawData( + "/api/documents/\(pathSegment(documentId))/images/upload", + fileName: "upload.\(ext)", mimeType: mimeType, fileData: data + ) struct UploadResponse: Decodable { let url: String } return try decoder.decode(UploadResponse.self, from: responseData).url } @@ -836,22 +763,10 @@ final class APIClient { // MARK: - Video upload func uploadVideo(data: Data, mimeType: String) async throws -> String { - guard let url = URL(string: baseURL + "/api/messages/videos/upload") else { throw APIError.invalidURL } - let boundary = UUID().uuidString - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let ext = mimeType.contains("mp4") ? "mp4" : "mov" - var body = Data() - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - body.append(data) - body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - let (responseData, response) = try await session.data(for: request) - try checkResponse(data: responseData, response: response) + let responseData = try await postMultipartRawData( + "/api/messages/videos/upload", fileName: "upload.\(ext)", mimeType: mimeType, fileData: data + ) struct UploadResponse: Decodable { let url: String } return try decoder.decode(UploadResponse.self, from: responseData).url } @@ -997,13 +912,7 @@ final class APIClient { func unfollowUser(userId: String) async throws { let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId - guard let url = URL(string: baseURL + "/api/follow/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/follow/\(encoded)") } func followStatus(userId: String) async throws -> FollowStatus { @@ -1062,22 +971,11 @@ final class APIClient { } func deleteMessage(id: String) async throws { - var request = URLRequest(url: URL(string: baseURL + "/api/messages/" + id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!)!) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - let (_, response) = try await session.data(for: request) - guard let http = response as? HTTPURLResponse else { return } - if http.statusCode == 401 { - throw APIError.status(401) - } - if http.statusCode >= 400 { - if http.statusCode == 403 { - throw APIError.server("You can only delete your own messages.") - } - throw APIError.status(http.statusCode) + do { + try await delete("/api/messages/\(pathSegment(id))") + } catch APIError.status(403) { + // The route answers 403 with no body, so supply the copy here. + throw APIError.server("You can only delete your own messages.") } } @@ -1106,13 +1004,7 @@ final class APIClient { func deleteListConnection(id: String) async throws { let enc = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/lists/connections/\(enc)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/lists/connections/\(enc)") } // MARK: - List schema (structured) @@ -1135,8 +1027,7 @@ final class APIClient { request.httpBody = try camelCaseEncoder.encode(StructuredSchemaBody(properties: properties)) let (data, response) = try await session.data(for: request) if let http = response as? HTTPURLResponse, http.statusCode == 409 { - let msg = (try? decoder.decode(ErrorResponse.self, from: data))?.error - ?? "This property still contains data." + let msg = serverErrorMessage(from: data) ?? "This property still contains data." throw APIError.conflict(msg) } try checkResponse(data: data, response: response) @@ -1164,13 +1055,7 @@ final class APIClient { func removeFollower(userId: String) async throws { let encoded = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId - guard let url = URL(string: baseURL + "/api/follow/\(encoded)/remove") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/follow/\(encoded)/remove") } // MARK: - List watchers (Phase 6) @@ -1226,13 +1111,7 @@ final class APIClient { func removeWatcher(listId: String, userId: String) async throws { let encodedList = listId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? listId let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId - guard let url = URL(string: baseURL + "/api/lists/\(encodedList)/watchers/\(encodedUser)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/lists/\(encodedList)/watchers/\(encodedUser)") } // MARK: - Sharing (G2): share-links & document collaborators @@ -1252,13 +1131,7 @@ final class APIClient { func revokeShareLink(kind: ShareResourceKind, id: String, token: String) async throws { let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? token - guard let url = URL(string: baseURL + "/api/\(kind.pathSegment)/\(encodedId)/share-links/\(encodedToken)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/\(kind.pathSegment)/\(encodedId)/share-links/\(encodedToken)") } // MARK: - Sharing: email share-invites (owner-only send/list/revoke) @@ -1282,13 +1155,7 @@ final class APIClient { func revokeShareInvite(kind: ShareResourceKind, id: String, token: String) async throws { let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? token - guard let url = URL(string: baseURL + "/api/\(kind.pathSegment)/\(encodedId)/invites/\(encodedToken)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/\(kind.pathSegment)/\(encodedId)/invites/\(encodedToken)") } func documentCollaborators(id: String) async throws -> [DocumentCollaborator] { @@ -1322,13 +1189,7 @@ final class APIClient { func removeDocumentCollaborator(id: String, userId: String) async throws { let encodedId = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId - guard let url = URL(string: baseURL + "/api/documents/\(encodedId)/collaborators/\(encodedUser)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/documents/\(encodedId)/collaborators/\(encodedUser)") } func searchDocumentCollaboratorCandidates(id: String, query: String) async throws -> [WatcherCandidate] { @@ -1398,13 +1259,7 @@ final class APIClient { func deleteOrganization(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/organizations/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/organizations/\(encoded)") } func organizationMembers(id: String, limit: Int = 50, offset: Int = 0) async throws -> (members: [OrganizationMember], pagination: Pagination?) { @@ -1431,13 +1286,7 @@ final class APIClient { func removeOrganizationMember(id: String, userId: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id let encodedUser = userId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? userId - guard let url = URL(string: baseURL + "/api/organizations/\(encoded)/members/\(encodedUser)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/organizations/\(encoded)/members/\(encodedUser)") } func joinOrganization(organizationId: String) async throws { @@ -1491,13 +1340,7 @@ final class APIClient { func unblockUser(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/users/\(encoded)/block") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/users/\(encoded)/block") } func blockedUsers(limit: Int = 50, offset: Int = 0) async throws -> BlockedUsersResponse { @@ -1513,13 +1356,7 @@ final class APIClient { func unmuteUser(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/users/\(encoded)/mute") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/users/\(encoded)/mute") } func mutedUsers(limit: Int = 50, offset: Int = 0) async throws -> MutedUsersResponse { @@ -1535,13 +1372,7 @@ final class APIClient { func revokeSession(id: String) async throws { let encoded = id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id - guard let url = URL(string: baseURL + "/api/user/sessions/\(encoded)") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await delete("/api/user/sessions/\(encoded)") } // MARK: - Push notifications (Phase 9) @@ -1554,15 +1385,7 @@ final class APIClient { func unregisterPushDevice(token: String) async throws { struct Body: Encodable { let token: String } - guard let url = URL(string: baseURL + "/api/push/unregister") else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "DELETE" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(Body(token: token)) - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) + try await deleteCamel("/api/push/unregister", body: Body(token: token)) } // MARK: - Direct messages @@ -1640,169 +1463,11 @@ final class APIClient { /// Uploads a DM image (multipart field `file`). Requires a verified email — not a subscription. func uploadDMImage(data: Data, mimeType: String) async throws -> String { - guard let url = URL(string: baseURL + "/api/dm/images/upload") else { throw APIError.invalidURL } - let boundary = UUID().uuidString - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let ext = mimeType == "image/png" ? "png" : "jpg" - var body = Data() - body.append("--\(boundary)\r\n".data(using: .utf8)!) - body.append("Content-Disposition: form-data; name=\"file\"; filename=\"upload.\(ext)\"\r\n".data(using: .utf8)!) - body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!) - body.append(data) - body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) - request.httpBody = body - let (responseData, response) = try await session.data(for: request) - try checkResponse(data: responseData, response: response) + let responseData = try await postMultipartRawData( + "/api/dm/images/upload", fileName: "upload.\(ext)", mimeType: mimeType, fileData: data + ) struct UploadResponse: Decodable { let url: String } return try decoder.decode(UploadResponse.self, from: responseData).url } - - // MARK: - Private helpers - - private func getRawData(_ path: String) async throws -> Data { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "GET" - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) - return data - } - - private func postCamelRawData(_ path: String, body: B) async throws -> Data { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(body) - let (data, response) = try await session.data(for: request) - try checkResponse(data: data, response: response) - return data - } - - private func perform(_ request: URLRequest) async throws -> T { - let method = request.httpMethod ?? "GET" - let path = request.url?.path ?? "" - apiLog.debug("\(method) \(path) auth=\(request.value(forHTTPHeaderField: "Authorization") != nil)") - let (data, response) = try await session.data(for: request) - let status = (response as? HTTPURLResponse)?.statusCode ?? -1 - if status >= 400 { - let body = String(data: data, encoding: .utf8) ?? "" - apiLog.error("\(method) \(path) → \(status): \(body)") - } else { - apiLog.debug("\(method) \(path) → \(status) (\(data.count) bytes)") - } - try checkResponse(data: data, response: response) - do { - return try decoder.decode(T.self, from: data) - } catch { - apiLog.error("Decode failed for \(path): \(error)") - throw error - } - } - - private func get(_ path: String) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - return try await perform(request) - } - - private func put(_ path: String, body: B) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try encoder.encode(body) - return try await perform(request) - } - - private func patch(_ path: String, body: B) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try encoder.encode(body) - return try await perform(request) - } - - private func post(_ path: String, body: B, authenticated: Bool = true) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if authenticated, let token = bearerToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - request.httpBody = try encoder.encode(body) - return try await perform(request) - } - - private func postCamel(_ path: String, body: B) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(body) - return try await perform(request) - } - - private func putCamel(_ path: String, body: B) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "PUT" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(body) - return try await perform(request) - } - - private func patchCamel(_ path: String, body: B) async throws -> T { - guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } - var request = URLRequest(url: url) - request.httpMethod = "PATCH" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("application/json", forHTTPHeaderField: "Accept") - if let token = bearerToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - request.httpBody = try camelCaseEncoder.encode(body) - return try await perform(request) - } - - private func checkResponse(data: Data, response: URLResponse) throws { - guard let http = response as? HTTPURLResponse else { return } - if http.statusCode == 401 { - throw APIError.status(401) - } - if http.statusCode >= 400 { - let serverMessage = (try? decoder.decode(ErrorResponse.self, from: data))?.error - if http.statusCode == 403, let serverMessage { - throw APIError.forbidden(serverMessage) - } - if let serverMessage { - throw APIError.server(serverMessage) - } - throw APIError.status(http.statusCode) - } - } -} - -private struct ErrorResponse: Decodable { - let error: String } diff --git a/InterlinedList/Services/APIClientTransport.swift b/InterlinedList/Services/APIClientTransport.swift new file mode 100644 index 0000000..4b0b286 --- /dev/null +++ b/InterlinedList/Services/APIClientTransport.swift @@ -0,0 +1,244 @@ +// +// APIClientTransport.swift +// InterlinedList +// + +import Foundation +import os.log + +private let transportLog = Logger(subsystem: "com.interlinedlist.app", category: "APIClient") + +/// The HTTP seam every `APIClient` endpoint is built on: URL assembly, auth +/// header, body encoding, status checking, decoding. +/// +/// **Why these are `internal` and not `private`.** Swift's `private` is +/// file-scoped, so a `private` verb helper living in `APIClient.swift` is +/// invisible to an `extension APIClient` in any *other* file. That made +/// `APIClient.swift` a single 1800-line class body every new endpoint had to be +/// appended to. Keeping the transport here, at module scope, is what lets a +/// feature add its endpoints in its own `APIClient+.swift` instead +/// (remember to register the new file in `project.pbxproj` — no synced groups). +/// +/// **Picking a verb helper.** Match the *body encoding the route expects*, not +/// the verb alone — the wrong one fails silently (see CLAUDE.md): +/// - `post` / `put` / `patch` — snake_case bodies (`convertToSnakeCase`) +/// - `postCamel` / `putCamel` / `patchCamel` — camelCase bodies, which the many +/// messages / lists / orgs / watchers / identities routes require +/// Responses always decode with `convertFromSnakeCase`. +extension APIClient { + + // MARK: - Reads + + func get(_ path: String) async throws -> T { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + authorize(&request) + return try await perform(request) + } + + /// GET returning the undecoded body — for routes that answer with something + /// other than JSON (CSV exports). + func getRawData(_ path: String) async throws -> Data { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "GET" + authorize(&request) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return data + } + + // MARK: - Writes (snake_case bodies) + + func post(_ path: String, body: B, authenticated: Bool = true) async throws -> T { + var request = try jsonRequest(path, method: "POST", authenticated: authenticated) + request.httpBody = try encoder.encode(body) + return try await perform(request) + } + + func put(_ path: String, body: B) async throws -> T { + var request = try jsonRequest(path, method: "PUT") + request.httpBody = try encoder.encode(body) + return try await perform(request) + } + + func patch(_ path: String, body: B) async throws -> T { + var request = try jsonRequest(path, method: "PATCH") + request.httpBody = try encoder.encode(body) + return try await perform(request) + } + + // MARK: - Writes (camelCase bodies) + + func postCamel(_ path: String, body: B) async throws -> T { + var request = try jsonRequest(path, method: "POST") + request.httpBody = try camelCaseEncoder.encode(body) + return try await perform(request) + } + + func putCamel(_ path: String, body: B) async throws -> T { + var request = try jsonRequest(path, method: "PUT") + request.httpBody = try camelCaseEncoder.encode(body) + return try await perform(request) + } + + func patchCamel(_ path: String, body: B) async throws -> T { + var request = try jsonRequest(path, method: "PATCH") + request.httpBody = try camelCaseEncoder.encode(body) + return try await perform(request) + } + + /// POST returning the undecoded body — for routes whose response is not a + /// fixed `Decodable` shape (the document sync push). + func postCamelRawData(_ path: String, body: B) async throws -> Data { + var request = try jsonRequest(path, method: "POST") + request.httpBody = try camelCaseEncoder.encode(body) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return data + } + + // MARK: - Deletes + + /// Bodyless DELETE with nothing to decode. Delete routes answer with `{}`, + /// `{ok:true}`, or `204 No Content` interchangeably, so the body is dropped; + /// a non-2xx still throws through `checkResponse`. + func delete(_ path: String) async throws { + _ = try await deleteRawData(path) + } + + /// DELETE whose response body carries state the caller needs (un-dig returns + /// the new dig count). + func deleteDecoding(_ path: String) async throws -> T { + let data = try await deleteRawData(path) + return try decoder.decode(T.self, from: data) + } + + /// DELETE with a camelCase JSON body — a few routes identify their target in + /// the body rather than the path (identity unlink, push unregister). + func deleteCamel(_ path: String, body: B) async throws { + var request = try jsonRequest(path, method: "DELETE") + request.httpBody = try camelCaseEncoder.encode(body) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + } + + private func deleteRawData(_ path: String) async throws -> Data { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = "DELETE" + request.setValue("application/json", forHTTPHeaderField: "Accept") + authorize(&request) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return data + } + + // MARK: - Uploads + + /// POSTs a single-file `multipart/form-data` body under the field name + /// `file`, which is the shape every upload route on the backend expects. + /// Returns the undecoded body so each caller decodes its own response shape. + func postMultipartRawData(_ path: String, fileName: String, mimeType: String, fileData: Data) async throws -> Data { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + let boundary = UUID().uuidString + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + authorize(&request) + request.httpBody = Self.multipartFileBody(boundary: boundary, fileName: fileName, mimeType: mimeType, fileData: fileData) + let (data, response) = try await session.data(for: request) + try checkResponse(data: data, response: response) + return data + } + + /// Percent-encodes one path segment. `?? segment` keeps the call sites free + /// of force-unwraps; encoding only fails for inputs a path can't hold anyway. + func pathSegment(_ segment: String) -> String { + segment.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? segment + } + + // MARK: - Private + + private func jsonRequest(_ path: String, method: String, authenticated: Bool = true) throws -> URLRequest { + guard let url = URL(string: baseURL + path) else { throw APIError.invalidURL } + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + if authenticated { authorize(&request) } + return request + } + + private func authorize(_ request: inout URLRequest) { + guard let bearerToken else { return } + request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization") + } + + /// `Data(_:utf8)` rather than `.data(using: .utf8)!` — the hand-rolled form + /// this replaces force-unwrapped five times per upload, on a production path. + private static func multipartFileBody(boundary: String, fileName: String, mimeType: String, fileData: Data) -> Data { + var body = Data() + body.append(Data("--\(boundary)\r\n".utf8)) + body.append(Data("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".utf8)) + body.append(Data("Content-Type: \(mimeType)\r\n\r\n".utf8)) + body.append(fileData) + body.append(Data("\r\n--\(boundary)--\r\n".utf8)) + return body + } + + private func perform(_ request: URLRequest) async throws -> T { + let method = request.httpMethod ?? "GET" + let path = request.url?.path ?? "" + transportLog.debug("\(method) \(path) auth=\(request.value(forHTTPHeaderField: "Authorization") != nil)") + let (data, response) = try await session.data(for: request) + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + if status >= 400 { + let body = String(data: data, encoding: .utf8) ?? "" + transportLog.error("\(method) \(path) → \(status): \(body)") + } else { + transportLog.debug("\(method) \(path) → \(status) (\(data.count) bytes)") + } + try checkResponse(data: data, response: response) + do { + return try decoder.decode(T.self, from: data) + } catch { + transportLog.error("Decode failed for \(path): \(error)") + throw error + } + } + + /// The `{"error": …}` message a route sends alongside a failure, when it + /// sends one. Endpoints that map a status themselves (409 → `.conflict`) + /// read the message through this rather than the wire type. + func serverErrorMessage(from data: Data) -> String? { + (try? decoder.decode(ErrorResponse.self, from: data))?.error + } + + /// Maps a non-2xx response onto `APIError`. Note 401 is deliberately a bare + /// `.status(401)`: it does **not** mean "logged out" — some routes only accept + /// session cookies and reject a valid Bearer — so views re-validate through + /// `authState.handleUnauthorized()` rather than logging out (CLAUDE.md). + func checkResponse(data: Data, response: URLResponse) throws { + guard let http = response as? HTTPURLResponse else { return } + if http.statusCode == 401 { + throw APIError.status(401) + } + if http.statusCode >= 400 { + let serverMessage = serverErrorMessage(from: data) + if http.statusCode == 403, let serverMessage { + throw APIError.forbidden(serverMessage) + } + if let serverMessage { + throw APIError.server(serverMessage) + } + throw APIError.status(http.statusCode) + } + } +} + +private struct ErrorResponse: Decodable { + let error: String +}