import Foundation // MARK: - Data Models struct ShelfSummary: Identifiable, Decodable, Hashable { let id: Int let name: String let slug: String } struct BookSummary: Identifiable, Decodable, Hashable { let id: Int let name: String let slug: String } struct ChapterSummary: Identifiable, Decodable, Hashable { let id: Int let name: String } struct PageResult: Decodable { let id: Int let name: String } // MARK: - Error enum ShareAPIError: LocalizedError { case notConfigured case networkError(Error) case httpError(Int) case decodingError var errorDescription: String? { switch self { case .notConfigured: return NSLocalizedString("error.notConfigured", bundle: .main, comment: "") case .networkError(let err): return String(format: NSLocalizedString("error.network.format", bundle: .main, comment: ""), err.localizedDescription) case .httpError(let code): return String(format: NSLocalizedString("error.http.format", bundle: .main, comment: ""), code) case .decodingError: return NSLocalizedString("error.decoding", bundle: .main, comment: "") } } } // MARK: - Protocol (for testability) protocol ShareAPIServiceProtocol: Sendable { func fetchShelves() async throws -> [ShelfSummary] func fetchBooks(shelfId: Int) async throws -> [BookSummary] func fetchChapters(bookId: Int) async throws -> [ChapterSummary] func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult } // MARK: - Live Implementation actor ShareExtensionAPIService: ShareAPIServiceProtocol { private let baseURL: String private let tokenId: String private let tokenSecret: String private let session: URLSession init(serverURL: String, tokenId: String, tokenSecret: String) { self.baseURL = serverURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")) self.tokenId = tokenId self.tokenSecret = tokenSecret self.session = URLSession(configuration: .default) } // MARK: - API calls func fetchShelves() async throws -> [ShelfSummary] { let data = try await get(path: "/api/shelves?count=500") return try decode(PaginatedResult.self, from: data).data } func fetchBooks(shelfId: Int) async throws -> [BookSummary] { let data = try await get(path: "/api/shelves/\(shelfId)") return try decode(ShelfDetail.self, from: data).books ?? [] } func fetchChapters(bookId: Int) async throws -> [ChapterSummary] { let data = try await get(path: "/api/books/\(bookId)") let contents = try decode(BookDetail.self, from: data).contents ?? [] return contents.filter { $0.type == "chapter" } .map { ChapterSummary(id: $0.id, name: $0.name) } } func createPage(bookId: Int, chapterId: Int?, title: String, markdown: String) async throws -> PageResult { var body: [String: Any] = [ "book_id": bookId, "name": title, "markdown": markdown ] if let chapterId { body["chapter_id"] = chapterId } let bodyData = try JSONSerialization.data(withJSONObject: body) let url = try makeURL(path: "/api/pages") var request = URLRequest(url: url) request.httpMethod = "POST" request.httpBody = bodyData request.setValue("application/json", forHTTPHeaderField: "Content-Type") authorize(&request) let (data, response) = try await session.data(for: request) try validate(response) return try decode(PageResult.self, from: data) } // MARK: - Helpers private func get(path: String) async throws -> Data { let url = try makeURL(path: path) var request = URLRequest(url: url) authorize(&request) do { let (data, response) = try await session.data(for: request) try validate(response) return data } catch let error as ShareAPIError { throw error } catch { throw ShareAPIError.networkError(error) } } private func makeURL(path: String) throws -> URL { guard let url = URL(string: baseURL + path) else { throw ShareAPIError.notConfigured } return url } private func authorize(_ request: inout URLRequest) { request.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization") } private func validate(_ response: URLResponse) throws { guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let code = (response as? HTTPURLResponse)?.statusCode ?? 0 throw ShareAPIError.httpError(code) } } private func decode(_ type: T.Type, from data: Data) throws -> T { do { return try JSONDecoder().decode(type, from: data) } catch { throw ShareAPIError.decodingError } } } // MARK: - Response wrapper types (private) private struct PaginatedResult: Decodable { let data: [T] } private struct ShelfDetail: Decodable { let books: [BookSummary]? } private struct BookContentItem: Decodable { let id: Int let name: String let type: String } private struct BookDetail: Decodable { let contents: [BookContentItem]? }