Unit tests, Lokalisierung, ShareExtension
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
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]?
|
||||
}
|
||||
Reference in New Issue
Block a user