Files
tcms/macclient/Sources/TCMSMacClient/APIClient.swift
T
2026-07-05 15:58:51 -04:00

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")
}
}