- API, Mac client
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user