560 lines
22 KiB
Swift
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]
|
|
}
|