Files
bookstax/BookStaxShareExtension/ShareExtensionAPIService.swift
T

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]?
}