Support JSON request bodies

This commit is contained in:
Shadowfacts 2020-09-14 23:25:26 -04:00
parent 911e66a159
commit 2c1ba7926e
Signed by untrusted user: shadowfacts
GPG Key ID: 94A5AB95422746E5
8 changed files with 100 additions and 64 deletions

View File

@ -26,7 +26,7 @@ public class Client {
public var timeoutInterval: TimeInterval = 60 public var timeoutInterval: TimeInterval = 60
lazy var decoder: JSONDecoder = { static let decoder: JSONDecoder = {
let decoder = JSONDecoder() let decoder = JSONDecoder()
let formatter = DateFormatter() let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
@ -36,6 +36,16 @@ public class Client {
return decoder return decoder
}() }()
static let encoder: JSONEncoder = {
let encoder = JSONEncoder()
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SZ"
formatter.timeZone = TimeZone(abbreviation: "UTC")
formatter.locale = Locale(identifier: "en_US_POSIX")
encoder.dateEncodingStrategy = .formatted(formatter)
return encoder
}()
public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) { public init(baseURL: URL, accessToken: String? = nil, session: URLSession = .shared) {
self.baseURL = baseURL self.baseURL = baseURL
self.accessToken = accessToken self.accessToken = accessToken
@ -59,12 +69,12 @@ public class Client {
return return
} }
guard response.statusCode == 200 else { guard response.statusCode == 200 else {
let mastodonError = try? self.decoder.decode(MastodonError.self, from: data) let mastodonError = try? Client.decoder.decode(MastodonError.self, from: data)
let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode) let error: Error = mastodonError.flatMap { .mastodonError($0.description) } ?? .unexpectedStatus(response.statusCode)
completion(.failure(error)) completion(.failure(error))
return return
} }
guard let result = try? self.decoder.decode(Result.self, from: data) else { guard let result = try? Client.decoder.decode(Result.self, from: data) else {
completion(.failure(.invalidModel)) completion(.failure(.invalidModel))
return return
} }
@ -92,7 +102,7 @@ public class Client {
// MARK: - Authorization // MARK: - Authorization
public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) { public func registerApp(name: String, redirectURI: String, scopes: [Scope], website: URL? = nil, completion: @escaping Callback<RegisteredApplication>) {
let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: .parameters([ let request = Request<RegisteredApplication>(method: .post, path: "/api/v1/apps", body: ParametersBody([
"client_name" => name, "client_name" => name,
"redirect_uris" => redirectURI, "redirect_uris" => redirectURI,
"scopes" => scopes.scopeString, "scopes" => scopes.scopeString,
@ -109,7 +119,7 @@ public class Client {
} }
public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) { public func getAccessToken(authorizationCode: String, redirectURI: String, completion: @escaping Callback<LoginSettings>) {
let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: .parameters([ let request = Request<LoginSettings>(method: .post, path: "/oauth/token", body: ParametersBody([
"client_id" => clientID, "client_id" => clientID,
"client_secret" => clientSecret, "client_secret" => clientSecret,
"grant_type" => "authorization_code", "grant_type" => "authorization_code",
@ -168,13 +178,13 @@ public class Client {
} }
public static func block(domain: String) -> Request<Empty> { public static func block(domain: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: .parameters([ return Request<Empty>(method: .post, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain "domain" => domain
])) ]))
} }
public static func unblock(domain: String) -> Request<Empty> { public static func unblock(domain: String) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: .parameters([ return Request<Empty>(method: .delete, path: "/api/v1/domain_blocks", body: ParametersBody([
"domain" => domain "domain" => domain
])) ]))
} }
@ -185,7 +195,7 @@ public class Client {
} }
public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> { public static func createFilter(phrase: String, context: [Filter.Context], irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .post, path: "/api/v1/filters", body: .parameters([ return Request<Filter>(method: .post, path: "/api/v1/filters", body: ParametersBody([
"phrase" => phrase, "phrase" => phrase,
"irreversible" => irreversible, "irreversible" => irreversible,
"whole_word" => wholeWord, "whole_word" => wholeWord,
@ -209,7 +219,7 @@ public class Client {
} }
public static func followRemote(acct: String) -> Request<Account> { public static func followRemote(acct: String) -> Request<Account> {
return Request<Account>(method: .post, path: "/api/v1/follows", body: .parameters(["uri" => acct])) return Request<Account>(method: .post, path: "/api/v1/follows", body: ParametersBody(["uri" => acct]))
} }
// MARK: - Lists // MARK: - Lists
@ -222,12 +232,12 @@ public class Client {
} }
public static func createList(title: String) -> Request<List> { public static func createList(title: String) -> Request<List> {
return Request<List>(method: .post, path: "/api/v1/lists", body: .parameters(["title" => title])) return Request<List>(method: .post, path: "/api/v1/lists", body: ParametersBody(["title" => title]))
} }
// MARK: - Media // MARK: - Media
public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> { public static func upload(attachment: FormAttachment, description: String? = nil, focus: (Float, Float)? = nil) -> Request<Attachment> {
return Request<Attachment>(method: .post, path: "/api/v1/media", body: .formData([ return Request<Attachment>(method: .post, path: "/api/v1/media", body: FormDataBody([
"description" => description, "description" => description,
"focus" => focus "focus" => focus
], attachment)) ], attachment))
@ -259,7 +269,7 @@ public class Client {
} }
public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> { public static func report(account: Account, statuses: [Status], comment: String) -> Request<Report> {
return Request<Report>(method: .post, path: "/api/v1/reports", body: .parameters([ return Request<Report>(method: .post, path: "/api/v1/reports", body: ParametersBody([
"account_id" => account.id, "account_id" => account.id,
"comment" => comment "comment" => comment
] + "status_ids" => statuses.map { $0.id })) ] + "status_ids" => statuses.map { $0.id }))
@ -287,7 +297,7 @@ public class Client {
spoilerText: String? = nil, spoilerText: String? = nil,
visibility: Status.Visibility? = nil, visibility: Status.Visibility? = nil,
language: String? = nil) -> Request<Status> { language: String? = nil) -> Request<Status> {
return Request<Status>(method: .post, path: "/api/v1/statuses", body: .parameters([ return Request<Status>(method: .post, path: "/api/v1/statuses", body: ParametersBody([
"status" => text, "status" => text,
"content_type" => contentType.mimeType, "content_type" => contentType.mimeType,
"in_reply_to_id" => inReplyTo, "in_reply_to_id" => inReplyTo,

View File

@ -115,7 +115,7 @@ public final class Account: AccountProtocol, Decodable {
} }
public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> { public static func mute(_ account: Account, notifications: Bool? = nil) -> Request<Relationship> {
return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: .parameters([ return Request<Relationship>(method: .post, path: "/api/v1/accounts/\(account.id)/mute", body: ParametersBody([
"notifications" => notifications "notifications" => notifications
])) ]))
} }

View File

@ -20,7 +20,7 @@ public class Attachment: Codable {
public let blurHash: String? public let blurHash: String?
public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> { public static func update(_ attachment: Attachment, focus: (Float, Float)?, description: String?) -> Request<Attachment> {
return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: .formData([ return Request<Attachment>(method: .put, path: "/api/v1/media/\(attachment.id)", body: FormDataBody([
"description" => (description ?? attachment.description), "description" => (description ?? attachment.description),
"focus" => focus "focus" => focus
], nil)) ], nil))

View File

@ -23,7 +23,7 @@ public class Filter: Decodable {
} }
public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> { public static func update(_ filter: Filter, phrase: String? = nil, context: [Context]? = nil, irreversible: Bool? = nil, wholeWord: Bool? = nil, expiresAt: Date? = nil) -> Request<Filter> {
return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: .parameters([ return Request<Filter>(method: .put, path: "/api/v1/filters/\(filter.id)", body: ParametersBody([
"phrase" => (phrase ?? filter.phrase), "phrase" => (phrase ?? filter.phrase),
"irreversible" => (irreversible ?? filter.irreversible), "irreversible" => (irreversible ?? filter.irreversible),
"whole_word" => (wholeWord ?? filter.wholeWord), "whole_word" => (wholeWord ?? filter.wholeWord),

View File

@ -31,7 +31,7 @@ public class List: Decodable, Equatable, Hashable {
} }
public static func update(_ list: List, title: String) -> Request<List> { public static func update(_ list: List, title: String) -> Request<List> {
return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: .parameters(["title" => title])) return Request<List>(method: .put, path: "/api/v1/lists/\(list.id)", body: ParametersBody(["title" => title]))
} }
public static func delete(_ list: List) -> Request<Empty> { public static func delete(_ list: List) -> Request<Empty> {
@ -39,13 +39,13 @@ public class List: Decodable, Equatable, Hashable {
} }
public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> { public static func add(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters( return Request<Empty>(method: .post, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }
public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> { public static func remove(_ list: List, accounts accountIDs: [String]) -> Request<Empty> {
return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: .parameters( return Request<Empty>(method: .delete, path: "/api/v1/lists/\(list.id)/accounts", body: ParametersBody(
"account_ids" => accountIDs "account_ids" => accountIDs
)) ))
} }

View File

@ -34,7 +34,7 @@ public class Notification: Decodable {
} }
public static func dismiss(id notificationID: String) -> Request<Empty> { public static func dismiss(id notificationID: String) -> Request<Empty> {
return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: .parameters([ return Request<Empty>(method: .post, path: "/api/v1/notifications/dismiss", body: ParametersBody([
"id" => notificationID "id" => notificationID
])) ]))
} }

View File

@ -8,56 +8,82 @@
import Foundation import Foundation
enum Body { protocol Body {
case parameters([Parameter]?) var mimeType: String? { get }
case formData([Parameter]?, FormAttachment?) var data: Data? { get }
case empty
} }
extension Body { struct EmptyBody: Body {
private static let boundary: String = "PachydermBoundary" var mimeType: String? { nil }
var data: Data? { nil }
}
var data: Data? { struct ParametersBody: Body {
switch self { let parameters: [Parameter]?
case let .parameters(parameters):
return parameters?.urlEncoded.data(using: .utf8)
case let .formData(parameters, attachment):
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(Body.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
data.append("\(value)\r\n")
}
if let attachment = attachment {
data.append("--\(Body.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
data.append(attachment.data)
data.append("\r\n")
}
data.append("--\(Body.boundary)--\r\n") init(_ parmaeters: [Parameter]?) {
return data self.parameters = parmaeters
case .empty:
return nil
}
} }
var mimeType: String? { var mimeType: String? {
switch self { if parameters == nil || parameters!.isEmpty {
case let .parameters(parameters):
if parameters == nil {
return nil
}
return "application/x-www-form-urlencoded; charset=utf-8"
case let .formData(parameters, attachment):
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(Body.boundary)"
case .empty:
return nil return nil
} }
return "application/x-www-form-urlencoded; charset=utf-8"
}
var data: Data? {
return parameters?.urlEncoded.data(using: .utf8)
} }
} }
struct FormDataBody: Body {
private static let boundary = "PachydermBoundary"
let parameters: [Parameter]?
let attachment: FormAttachment?
init(_ parameters: [Parameter]?, _ attachment: FormAttachment?) {
self.parameters = parameters
self.attachment = attachment
}
var mimeType: String? {
if parameters == nil && attachment == nil {
return nil
}
return "multipart/form-data; boundary=\(FormDataBody.boundary)"
}
var data: Data? {
var data = Data()
parameters?.forEach { param in
guard let value = param.value else { return }
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"\(param.name)\"\r\n\r\n")
data.append("\(value)\r\n")
}
if let attachment = attachment {
data.append("--\(FormDataBody.boundary)\r\n")
data.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(attachment.fileName)\"\r\n")
data.append("Content-Type: \(attachment.mimeType)\r\n\r\n")
data.append(attachment.data)
data.append("\r\n")
}
data.append("--\(FormDataBody.boundary)--\r\n")
return data
}
}
struct JsonBody<T: Encodable>: Body {
let value: T
init(_ value: T) {
self.value = value
}
var mimeType: String? { "application/json" }
var data: Data? { try? Client.encoder.encode(value) }
}

View File

@ -14,7 +14,7 @@ public struct Request<ResultType: Decodable> {
let body: Body let body: Body
var queryParameters: [Parameter] var queryParameters: [Parameter]
init(method: Method, path: String, body: Body = .empty, queryParameters: [Parameter] = []) { init(method: Method, path: String, body: Body = EmptyBody(), queryParameters: [Parameter] = []) {
self.method = method self.method = method
self.path = path self.path = path
self.body = body self.body = body