181 lines
5.5 KiB
Swift
181 lines
5.5 KiB
Swift
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<ShelfSummary>.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<T: Decodable>(_ 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<T: Decodable>: 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]?
|
|
}
|