Files
bookstax/bookstax/Services/BookStackAPI.swift
T

560 lines
22 KiB
Swift

import Foundation
actor BookStackAPI {
static let shared = BookStackAPI()
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
private let decoder: JSONDecoder = {
let d = JSONDecoder()
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(abbreviation: "UTC")
d.dateDecodingStrategy = .formatted(formatter)
return d
}()
// MARK: - Configuration
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
var clean = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
while clean.hasSuffix("/") { clean = String(clean.dropLast()) }
self.serverURL = clean
self.tokenId = tokenId.trimmingCharacters(in: .whitespacesAndNewlines)
self.tokenSecret = tokenSecret.trimmingCharacters(in: .whitespacesAndNewlines)
AppLog(.info, "API configured for \(clean)", category: "API")
}
func getServerURL() -> String { serverURL }
// MARK: - Core Request (no body)
func request<T: Decodable & Sendable>(
endpoint: String,
method: String = "GET"
) async throws -> T {
try await performRequest(endpoint: endpoint, method: method, bodyData: nil)
}
// MARK: - Core Request (with body)
func request<T: Decodable & Sendable, Body: Encodable & Sendable>(
endpoint: String,
method: String,
body: Body
) async throws -> T {
let bodyData = try JSONEncoder().encode(body)
return try await performRequest(endpoint: endpoint, method: method, bodyData: bodyData)
}
// MARK: - Shared implementation
private func performRequest<T: Decodable & Sendable>(
endpoint: String,
method: String,
bodyData: Data?
) async throws -> T {
guard !serverURL.isEmpty else {
AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
throw BookStackError.notAuthenticated
}
guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
throw BookStackError.invalidURL
}
AppLog(.debug, "\(method) /api/\(endpoint)", category: "API")
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
if let bodyData {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = bodyData
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
let mapped: BookStackError
switch urlError.code {
case .timedOut:
mapped = .timeout
case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable
case .cannotFindHost, .dnsLookupFailed:
mapped = .notReachable(host: serverURL)
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
mapped = .sslError
default:
mapped = .unknown(urlError.localizedDescription)
}
AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
throw mapped
}
guard let http = response as? HTTPURLResponse else {
AppLog(.error, "\(method) /api/\(endpoint) — invalid response type", category: "API")
throw BookStackError.unknown("Invalid response type")
}
AppLog(.debug, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)", category: "API")
guard (200..<300).contains(http.statusCode) else {
let errorMessage = parseErrorMessage(from: data)
let mapped: BookStackError
switch http.statusCode {
case 401: mapped = .unauthorized
case 403: mapped = .forbidden
case 404: mapped = .notFound(resource: "Resource")
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
}
AppLog(.error, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)\(errorMessage.map { ": \($0)" } ?? "")", category: "API")
throw mapped
}
// Handle 204 No Content
if http.statusCode == 204 {
guard let empty = EmptyResponse() as? T else {
throw BookStackError.decodingError("Expected empty response for 204")
}
return empty
}
do {
let result = try decoder.decode(T.self, from: data)
return result
} catch {
AppLog(.error, "\(method) /api/\(endpoint) — decode error: \(error.localizedDescription)", category: "API")
throw BookStackError.decodingError(error.localizedDescription)
}
}
private func parseErrorMessage(from data: Data) -> String? {
struct APIErrorEnvelope: Codable {
struct Inner: Codable { let message: String? }
let error: Inner?
}
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
}
// MARK: - Shelves
func fetchShelves(offset: Int = 0, count: Int = 100) async throws -> [ShelfDTO] {
let response: PaginatedResponse<ShelfDTO> = try await request(
endpoint: "shelves?offset=\(offset)&count=\(count)&sort=+name"
)
return response.data
}
func fetchShelf(id: Int) async throws -> ShelfBooksResponse {
return try await request(endpoint: "shelves/\(id)")
}
func createShelf(name: String, shelfDescription: String) async throws -> ShelfDTO {
struct Body: Encodable, Sendable {
let name: String
let description: String
}
return try await request(endpoint: "shelves", method: "POST",
body: Body(name: name, description: shelfDescription))
}
/// Assign a list of book IDs to a shelf. Replaces the shelf's current book list.
func updateShelfBooks(shelfId: Int, bookIds: [Int]) async throws {
struct BookRef: Encodable, Sendable { let id: Int }
struct Body: Encodable, Sendable { let books: [BookRef] }
let _: ShelfDTO = try await request(
endpoint: "shelves/\(shelfId)",
method: "PUT",
body: Body(books: bookIds.map { BookRef(id: $0) })
)
}
func deleteShelf(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "shelves/\(id)", method: "DELETE")
}
// MARK: - Books
func fetchBooks(offset: Int = 0, count: Int = 100) async throws -> [BookDTO] {
let response: PaginatedResponse<BookDTO> = try await request(
endpoint: "books?offset=\(offset)&count=\(count)&sort=+name"
)
return response.data
}
func fetchBook(id: Int) async throws -> BookDTO {
return try await request(endpoint: "books/\(id)")
}
func createBook(name: String, bookDescription: String) async throws -> BookDTO {
struct Body: Encodable, Sendable {
let name: String
let description: String
}
return try await request(endpoint: "books", method: "POST",
body: Body(name: name, description: bookDescription))
}
func deleteBook(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "books/\(id)", method: "DELETE")
}
// MARK: - Chapters
func fetchChapter(id: Int) async throws -> ChapterDTO {
return try await request(endpoint: "chapters/\(id)")
}
func fetchChapters(bookId: Int) async throws -> [ChapterDTO] {
let response: PaginatedResponse<ChapterDTO> = try await request(
endpoint: "chapters?filter[book_id]=\(bookId)&count=100&sort=+priority"
)
return response.data
}
func createChapter(bookId: Int, name: String, chapterDescription: String) async throws -> ChapterDTO {
struct Body: Encodable, Sendable {
let bookId: Int
let name: String
let description: String
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case name, description
}
}
return try await request(endpoint: "chapters", method: "POST",
body: Body(bookId: bookId, name: name, description: chapterDescription))
}
func deleteChapter(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "chapters/\(id)", method: "DELETE")
}
// MARK: - Pages
func fetchPages(bookId: Int) async throws -> [PageDTO] {
let response: PaginatedResponse<PageDTO> = try await request(
endpoint: "pages?filter[book_id]=\(bookId)&count=100&sort=+priority"
)
return response.data
}
func fetchPage(id: Int) async throws -> PageDTO {
return try await request(endpoint: "pages/\(id)")
}
func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
struct TagBody: Encodable, Sendable {
let name: String
let value: String
}
struct Body: Encodable, Sendable {
let bookId: Int
let chapterId: Int?
let name: String
let markdown: String
let tags: [TagBody]
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case chapterId = "chapter_id"
case name, markdown, tags
}
}
return try await request(endpoint: "pages", method: "POST",
body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown,
tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
}
func updatePage(id: Int, name: String, markdown: String, tags: [TagDTO] = []) async throws -> PageDTO {
struct TagBody: Encodable, Sendable {
let name: String
let value: String
}
struct Body: Encodable, Sendable {
let name: String
let markdown: String
let tags: [TagBody]
}
return try await request(endpoint: "pages/\(id)", method: "PUT",
body: Body(name: name, markdown: markdown,
tags: tags.map { TagBody(name: $0.name, value: $0.value) }))
}
func deletePage(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
}
// MARK: - Tags
func fetchTags(count: Int = 200) async throws -> [TagDTO] {
let response: TagListResponseDTO = try await request(
endpoint: "tags?count=\(count)&sort=+name"
)
return response.data
}
// MARK: - Search
func search(query: String, type: SearchResultDTO.ContentType? = nil, tag: String? = nil) async throws -> SearchResponseDTO {
var q = query
if let type { q += " [type:\(type.rawValue)]" }
if let tag { q += " [tag:\(tag)]" }
let encoded = q.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? q
return try await request(endpoint: "search?query=\(encoded)")
}
// MARK: - Comments
func fetchComments(pageId: Int) async throws -> [CommentDTO] {
let response: PaginatedResponse<CommentDTO> = try await request(
endpoint: "comments?entity_type=page&entity_id=\(pageId)"
)
return response.data
}
func postComment(pageId: Int, text: String) async throws -> CommentDTO {
struct Body: Encodable, Sendable {
let text: String
let entityId: Int
let entityType: String
enum CodingKeys: String, CodingKey {
case text
case entityId = "entity_id"
case entityType = "entity_type"
}
}
return try await request(endpoint: "comments", method: "POST",
body: Body(text: text, entityId: pageId, entityType: "page"))
}
func deleteComment(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "comments/\(id)", method: "DELETE")
}
// MARK: - System Info & Users
/// Verify that the given URL is a reachable BookStack instance AND that the
/// supplied token is valid all in one request to GET /api/system.
///
/// /api/system requires authentication and returns instance info on success:
/// 200 server reachable + token valid returns APIInfo
/// 401 server reachable but token invalid
/// 403 server reachable but account lacks API permission
/// 5xx / network error server unreachable
func verifyConnection(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
// Normalise defensively in case caller skipped validateServerURL
var cleanURL = url.trimmingCharacters(in: .whitespacesAndNewlines)
while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
AppLog(.info, "Verifying connection to \(cleanURL)", category: "Auth")
guard let requestURL = URL(string: "\(cleanURL)/api/system") else {
AppLog(.error, "Invalid server URL: \(cleanURL)", category: "Auth")
throw BookStackError.invalidURL
}
var req = URLRequest(url: requestURL)
req.httpMethod = "GET"
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 15
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
AppLog(.error, "Network error reaching \(url): \(urlError.localizedDescription)", category: "Auth")
switch urlError.code {
case .timedOut:
throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost:
throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
throw BookStackError.sslError
default:
throw BookStackError.notReachable(host: url)
}
}
guard let http = response as? HTTPURLResponse else {
throw BookStackError.notReachable(host: url)
}
AppLog(.debug, "GET /api/system → HTTP \(http.statusCode)", category: "Auth")
switch http.statusCode {
case 200:
// Decode the system info
if let info = try? decoder.decode(APIInfo.self, from: data) {
AppLog(.info, "Connected to BookStack \(info.version)\(info.appName ?? "unknown")", category: "Auth")
return info
}
// Unexpected body but 200 treat as success with unknown version
AppLog(.warning, "GET /api/system returned 200 but unexpected body", category: "Auth")
return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)
case 401:
throw BookStackError.unauthorized
case 403:
let msg = parseErrorMessage(from: data)
AppLog(.error, "GET /api/system → 403: \(msg ?? "forbidden")", category: "Auth")
throw BookStackError.forbidden
case 404:
// Old BookStack version without /api/system fall back to /api/books probe
AppLog(.warning, "/api/system not found (older BookStack?), falling back to /api/books", category: "Auth")
return try await verifyViaBooks(url: url, tokenId: tokenId, tokenSecret: tokenSecret)
case 500...:
throw BookStackError.notReachable(host: url)
default:
throw BookStackError.httpError(statusCode: http.statusCode, message: nil)
}
}
/// Fallback for older BookStack instances that don't have /api/system.
private func verifyViaBooks(url: String, tokenId: String, tokenSecret: String) async throws -> APIInfo {
guard let requestURL = URL(string: "\(url)/api/books?count=1") else {
throw BookStackError.invalidURL
}
var req = URLRequest(url: requestURL)
req.httpMethod = "GET"
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 15
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
switch urlError.code {
case .timedOut: throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
throw BookStackError.sslError
default: throw BookStackError.notReachable(host: url)
}
}
guard let http = response as? HTTPURLResponse else {
throw BookStackError.notReachable(host: url)
}
switch http.statusCode {
case 200:
AppLog(.info, "Fallback /api/books succeeded — BookStack confirmed", category: "Auth")
return APIInfo(version: "unknown", appName: nil, instanceId: nil, baseUrl: nil)
case 401: throw BookStackError.unauthorized
case 403: throw BookStackError.forbidden
default:
let body = String(data: data, encoding: .utf8) ?? ""
throw BookStackError.notReachable(host: "\(url) (HTTP \(http.statusCode): \(body.prefix(200)))")
}
}
func verifyConnection() async throws -> APIInfo {
return try await request(endpoint: "info")
}
func fetchCurrentUser() async throws -> UserDTO {
// Fetch the users list to get current user info
let response: PaginatedResponse<UserDTO> = try await request(endpoint: "users?count=1")
guard let user = response.data.first else {
throw BookStackError.notFound(resource: "Current user")
}
return user
}
// MARK: - Image Gallery
/// Upload an image to the BookStack image gallery.
/// - Parameters:
/// - data: Raw image bytes (JPEG or PNG)
/// - filename: Filename including extension, e.g. "photo.jpg"
/// - mimeType: e.g. "image/jpeg" or "image/png"
/// - pageId: The page this image belongs to. Use 0 for new pages not yet saved.
func uploadImage(data: Data, filename: String, mimeType: String, pageId: Int) async throws -> ImageUploadResponse {
guard !serverURL.isEmpty else { throw BookStackError.notAuthenticated }
guard let url = URL(string: "\(serverURL)/api/image-gallery") else { throw BookStackError.invalidURL }
let boundary = "Boundary-\(UUID().uuidString)"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
appendField("type", "gallery")
appendField("uploaded_to", "\(pageId)")
appendField("name", filename)
// File field
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"image\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n".data(using: .utf8)!)
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
var req = URLRequest(url: url)
req.httpMethod = "POST"
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.httpBody = body
req.timeoutInterval = 60
AppLog(.info, "Uploading image '\(filename)' (\(data.count) bytes) to page \(pageId)", category: "API")
let (responseData, response): (Data, URLResponse)
do {
(responseData, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
throw BookStackError.unknown(urlError.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw BookStackError.unknown("Invalid response")
}
AppLog(.debug, "POST /api/image-gallery → HTTP \(http.statusCode)", category: "API")
guard http.statusCode == 200 else {
let msg = parseErrorMessage(from: responseData)
throw BookStackError.httpError(statusCode: http.statusCode, message: msg)
}
return try decoder.decode(ImageUploadResponse.self, from: responseData)
}
}
// MARK: - Helper
nonisolated struct EmptyResponse: Codable, Sendable {
init() {}
}
nonisolated struct ShelfBooksResponse: Codable, Sendable {
let id: Int
let name: String
let books: [BookDTO]
}