255 lines
8.7 KiB
Swift
255 lines
8.7 KiB
Swift
import Foundation
|
|
|
|
struct TCMSAPIClient {
|
|
var baseURL: URL
|
|
var token: String
|
|
|
|
private var endpointURL: URL {
|
|
if baseURL.lastPathComponent.lowercased() == "api.php" {
|
|
return baseURL
|
|
}
|
|
return baseURL.appendingPathComponent("api.php")
|
|
}
|
|
|
|
private var encoder: JSONEncoder {
|
|
let encoder = JSONEncoder()
|
|
encoder.outputFormatting = [.sortedKeys]
|
|
return encoder
|
|
}
|
|
|
|
private var decoder: JSONDecoder {
|
|
JSONDecoder()
|
|
}
|
|
|
|
func ping() async throws -> PingResponse {
|
|
try await request(action: "ping")
|
|
}
|
|
|
|
func listContent(type: String, includeDrafts: Bool = true) async throws -> [ContentItem] {
|
|
let response: ContentListResponse = try await request(
|
|
action: "content.list",
|
|
query: [
|
|
URLQueryItem(name: "type", value: type),
|
|
URLQueryItem(name: "include_drafts", value: includeDrafts ? "1" : "0")
|
|
]
|
|
)
|
|
return response.items
|
|
}
|
|
|
|
func getContent(slug: String) async throws -> ContentItem {
|
|
let response: ContentItemResponse = try await request(
|
|
action: "content.get",
|
|
query: [URLQueryItem(name: "slug", value: slug)]
|
|
)
|
|
return response.item
|
|
}
|
|
|
|
func saveContent(_ item: ContentItem, originalSlug: String, markdown: String) async throws -> ContentItem {
|
|
let body = ContentSaveRequest(
|
|
originalSlug: originalSlug,
|
|
slug: item.slug,
|
|
markdown: markdown,
|
|
metadata: ContentMetadata(item: item)
|
|
)
|
|
let response: ContentItemResponse = try await request(action: "content.save", method: "POST", body: body)
|
|
return response.item
|
|
}
|
|
|
|
func deleteContent(slug: String) async throws -> Bool {
|
|
let body = SlugRequest(slug: slug)
|
|
let response: BasicResponse = try await request(action: "content.delete", method: "POST", body: body)
|
|
return response.ok
|
|
}
|
|
|
|
func listMedia() async throws -> [MediaItem] {
|
|
let response: MediaListResponse = try await request(action: "media.list")
|
|
return response.items
|
|
}
|
|
|
|
func uploadMedia(fileURL: URL, folder: String = "") async throws -> MediaItem {
|
|
var request = try makeRequest(action: "media.upload", method: "POST")
|
|
let boundary = "TCMSBoundary-\(UUID().uuidString)"
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
var body = Data()
|
|
if !folder.isEmpty {
|
|
body.appendMultipartField(name: "folder", value: folder, boundary: boundary)
|
|
}
|
|
let data = try Data(contentsOf: fileURL)
|
|
body.appendMultipartFile(
|
|
name: "file",
|
|
filename: fileURL.lastPathComponent,
|
|
mimeType: Self.mimeType(for: fileURL),
|
|
data: data,
|
|
boundary: boundary
|
|
)
|
|
body.appendString("--\(boundary)--\r\n")
|
|
|
|
let (responseData, response) = try await URLSession.shared.upload(for: request, from: body)
|
|
try validate(responseData: responseData, response: response)
|
|
return try decoder.decode(MediaItemResponse.self, from: responseData).item
|
|
}
|
|
|
|
func listComments(status: String, slug: String = "") async throws -> [CommentItem] {
|
|
var query = [URLQueryItem]()
|
|
if status != "all" {
|
|
query.append(URLQueryItem(name: "status", value: status))
|
|
}
|
|
if !slug.isEmpty {
|
|
query.append(URLQueryItem(name: "slug", value: slug))
|
|
}
|
|
let response: CommentListResponse = try await request(action: "comments.list", query: query)
|
|
return response.items
|
|
}
|
|
|
|
func setCommentStatus(id: Int, status: String) async throws -> Bool {
|
|
let response: BasicResponse = try await request(
|
|
action: "comments.status",
|
|
method: "POST",
|
|
body: CommentStatusRequest(id: id, status: status)
|
|
)
|
|
return response.ok
|
|
}
|
|
|
|
func deleteComment(id: Int) async throws -> Bool {
|
|
let response: BasicResponse = try await request(
|
|
action: "comments.delete",
|
|
method: "POST",
|
|
body: CommentDeleteRequest(id: id)
|
|
)
|
|
return response.ok
|
|
}
|
|
|
|
private func request<Response: Decodable>(
|
|
action: String,
|
|
query: [URLQueryItem] = [],
|
|
method: String = "GET"
|
|
) async throws -> Response {
|
|
let request = try makeRequest(action: action, query: query, method: method)
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try validate(responseData: data, response: response)
|
|
return try decoder.decode(Response.self, from: data)
|
|
}
|
|
|
|
private func request<Body: Encodable, Response: Decodable>(
|
|
action: String,
|
|
query: [URLQueryItem] = [],
|
|
method: String = "POST",
|
|
body: Body
|
|
) async throws -> Response {
|
|
var request = try makeRequest(action: action, query: query, method: method)
|
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
request.httpBody = try encoder.encode(body)
|
|
let (data, response) = try await URLSession.shared.data(for: request)
|
|
try validate(responseData: data, response: response)
|
|
return try decoder.decode(Response.self, from: data)
|
|
}
|
|
|
|
private func makeRequest(action: String, query: [URLQueryItem] = [], method: String) throws -> URLRequest {
|
|
guard var components = URLComponents(url: endpointURL, resolvingAgainstBaseURL: false) else {
|
|
throw TCMSClientError.invalidURL
|
|
}
|
|
components.queryItems = [URLQueryItem(name: "action", value: action)] + query
|
|
guard let url = components.url else {
|
|
throw TCMSClientError.invalidURL
|
|
}
|
|
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.timeoutInterval = 30
|
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
}
|
|
return request
|
|
}
|
|
|
|
private func validate(responseData: Data, response: URLResponse) throws {
|
|
guard let http = response as? HTTPURLResponse else {
|
|
return
|
|
}
|
|
guard (200..<300).contains(http.statusCode) else {
|
|
let decoded = try? decoder.decode(APIErrorResponse.self, from: responseData)
|
|
throw TCMSClientError.server(decoded?.error ?? "HTTP \(http.statusCode)")
|
|
}
|
|
}
|
|
|
|
private static func mimeType(for url: URL) -> String {
|
|
switch url.pathExtension.lowercased() {
|
|
case "jpg", "jpeg": return "image/jpeg"
|
|
case "png": return "image/png"
|
|
case "gif": return "image/gif"
|
|
case "webp": return "image/webp"
|
|
case "svg": return "image/svg+xml"
|
|
case "mp4", "m4v": return "video/mp4"
|
|
case "webm": return "video/webm"
|
|
case "mp3": return "audio/mpeg"
|
|
case "wav": return "audio/wav"
|
|
case "pdf": return "application/pdf"
|
|
default: return "application/octet-stream"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum TCMSClientError: LocalizedError {
|
|
case invalidURL
|
|
case server(String)
|
|
case missingServer
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .invalidURL:
|
|
return "The TCMS URL is not valid."
|
|
case .server(let message):
|
|
return message
|
|
case .missingServer:
|
|
return "Set a TCMS URL in Settings first."
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ContentSaveRequest: Encodable {
|
|
var originalSlug: String
|
|
var slug: String
|
|
var markdown: String
|
|
var metadata: ContentMetadata
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case originalSlug = "original_slug"
|
|
case slug, markdown, metadata
|
|
}
|
|
}
|
|
|
|
private struct SlugRequest: Encodable {
|
|
var slug: String
|
|
}
|
|
|
|
private struct CommentStatusRequest: Encodable {
|
|
var id: Int
|
|
var status: String
|
|
}
|
|
|
|
private struct CommentDeleteRequest: Encodable {
|
|
var id: Int
|
|
}
|
|
|
|
private extension Data {
|
|
mutating func appendString(_ string: String) {
|
|
append(Data(string.utf8))
|
|
}
|
|
|
|
mutating func appendMultipartField(name: String, value: String, boundary: String) {
|
|
appendString("--\(boundary)\r\n")
|
|
appendString("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n")
|
|
appendString("\(value)\r\n")
|
|
}
|
|
|
|
mutating func appendMultipartFile(name: String, filename: String, mimeType: String, data: Data, boundary: String) {
|
|
appendString("--\(boundary)\r\n")
|
|
appendString("Content-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\n")
|
|
appendString("Content-Type: \(mimeType)\r\n\r\n")
|
|
append(data)
|
|
appendString("\r\n")
|
|
}
|
|
}
|