writefreely-swift/Sources/WriteFreely/WFClient.swift

813 lines
31 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
// MARK: - URLSession-related protocols
/// Define requirements for `URLSession`s here for dependency-injection purposes (specifically, for testing).
public protocol URLSessionProtocol {
typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void
func dataTask(with request: URLRequest, completionHandler: @escaping DataTaskResult) -> URLSessionDataTaskProtocol
}
/// Define requirements for `URLSessionDataTask`s here for dependency-injection purposes (specifically, for testing).
public protocol URLSessionDataTaskProtocol {
func resume()
}
// MARK: - Class definition
public class WFClient {
let decoder: JSONDecoder
let session: URLSessionProtocol
public var requestURL: URL
public var user: WFUser?
/// Initializes the WriteFreely client.
///
/// Required for connecting to the API endpoints of a WriteFreely instance.
///
/// - Parameters:
/// - instanceURL: The URL for the WriteFreely instance to which we're connecting, including the protocol.
/// - session: The URL session to use for connections; defaults to `URLSession.shared`.
public init(for instanceURL: URL, with session: URLSessionProtocol = URLSession.shared) {
decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.session = session
// TODO: - Check that the protocol for instanceURL is HTTPS
requestURL = URL(string: "api/", relativeTo: instanceURL) ?? instanceURL
}
// MARK: - Collection-related methods
/// Creates a new collection.
///
/// If only a `title` is given, the server will generate and return an alias; in this case, clients should store
/// the returned `alias` for future operations.
///
/// - Parameters:
/// - token: The access token for the user creating the collection.
/// - title: The title of the new collection.
/// - alias: The alias of the collection.
/// - completion: A handler for the returned `WFCollection` on success, or `Error` on failure.
public func createCollection(
token: String? = nil,
withTitle title: String,
alias: String? = nil,
completion: @escaping (Result<WFCollection, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
var bodyObject: [String: Any]
if let alias = alias {
bodyObject = [
"alias": alias,
"title": title
]
} else {
bodyObject = [
"title": title
]
}
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 201) { result in
switch result {
case .success(let data):
do {
let collection = try self.decoder.decode(ServerData<WFCollection>.self, from: data)
completion(.success(collection.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Retrieves a collection's metadata.
///
/// Collections can be retrieved without authentication. However, authentication is required for retrieving a
/// private collection or one with scheduled posts.
///
/// - Parameters:
/// - token: The access token for the user retrieving the collection.
/// - alias: The alias for the collection to be retrieved.
/// - completion: A handler for the returned `WFCollection` on success, or `Error` on failure.
public func getCollection(
token: String? = nil,
withAlias alias: String,
completion: @escaping (Result<WFCollection, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections/\(alias)", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
do {
let collection = try self.decoder.decode(ServerData<WFCollection>.self, from: data)
completion(.success(collection.data))
} catch {
completion(.failure(WFError.invalidData))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Permanently deletes a collection.
///
/// Any posts in the collection are not deleted; rather, they are made anonymous.
///
/// - Parameters:
/// - token: The access token for the user deleting the collection.
/// - alias: The alias for the collection to be deleted.
/// - completion: A hander for the returned `Bool` on success, or `Error` on failure.
public func deleteCollection(
token: String? = nil,
withAlias alias: String,
completion: @escaping (Result<Bool, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections/\(alias)", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
delete(with: request) { result in
switch result {
case .success(_):
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
// MARK: - Post-related methods
/// Retrieves an array of posts.
///
/// If the `collectionAlias` argument is provided, an array of all posts in that collection is retrieved; if
/// omitted, an array of all posts created by the user whose access token is provided is retrieved.
///
/// Collection posts can be retrieved without authentication; however, authentication is required for retrieving a
/// private collection or one with scheduled posts.
///
/// - Parameters:
/// - token: The access token for the user retrieving the posts.
/// - collectionAlias: The alias for the collection whose posts are to be retrieved.
/// - completion: A handler for the returned `[WFPost]` on success, or `Error` on failure.
public func getPosts(
token: String? = nil,
in collectionAlias: String? = nil,
completion: @escaping (Result<[WFPost], Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
var path = ""
if let alias = collectionAlias {
// TODO: - Check here that the collection alias exists.
path = "collections/\(alias)/posts"
} else {
path = "me/posts"
}
guard let url = URL(string: path, relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
do {
// The response is formatted differently depending on if we're getting user posts or collection
// posts,so we need to determine what kind of structure we're decoding based on the
// collectionAlias argument.
if collectionAlias != nil {
let post = try self.decoder.decode(NestedPostsJson.self, from: data)
completion(.success(post.data))
} else {
let post = try self.decoder.decode(ServerData<[WFPost]>.self, from: data)
completion(.success(post.data))
}
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Moves a post to a collection.
///
/// - Attention: **INCOMPLETE IMPLEMENTATION**
/// - The closure should return a result type of `<[WFPost], Error>`.
/// - The modifyToken for the post is currently ignored.
///
/// - Parameters:
/// - token: The access token for the user moving the post to a collection.
/// - postId: The ID of the post to add to the collection.
/// - modifyToken: The post's modify token; required if the post doesn't belong to the requesting user. If `collectionAlias` is `nil`, do not include a `modifyToken`.
/// - collectionAlias: The alias of the collection to which the post should be added; if `nil`, this removes the post from any collection.
/// - completion: A handler for the returned `Bool` on success, or `Error` on failure.
public func movePost(
token: String? = nil,
postId: String,
with modifyToken: String? = nil,
to collectionAlias: String?,
completion: @escaping (Result<Bool, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
if collectionAlias == nil && modifyToken != nil { completion(.failure(WFError.badRequest)) }
var urlString = ""
if let collectionAlias = collectionAlias {
urlString = "collections/\(collectionAlias)/collect"
} else {
urlString = "posts/disperse"
}
guard let url = URL(string: urlString, relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
var bodyObject: [Any]
if let modifyToken = modifyToken {
bodyObject = [ [ "id": postId, "token": modifyToken ] ]
} else {
bodyObject = collectionAlias == nil ? [ postId ] : [ [ "id": postId ] ]
}
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 200) { result in
switch result {
case .success(_):
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
/// Pins a post to a collection.
///
/// Pinning a post to a collection adds it as a navigation item in the collection/blog home page header, rather
/// than on the blog itself. While the API endpoint can take an array of posts, this function only accepts a single
/// post.
///
/// - Parameters:
/// - token: The access token of the user pinning the post to the collection.
/// - postId: The ID of the post to be pinned.
/// - position: The numeric position in which to pin the post; if `nil`, will pin at the end of the list.
/// - collectionAlias: The alias of the collection to which the post should be pinned.
/// - completion: A handler for the `Bool` returned on success, or `Error` on failure.
public func pinPost(
token: String? = nil,
postId: String,
at position: Int? = nil,
in collectionAlias: String,
completion: @escaping (Result<Bool, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections/\(collectionAlias)/pin", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
var bodyObject: [[String: Any]]
if let position = position {
bodyObject = [
[
"id": postId,
"position": position
]
]
} else {
bodyObject = [
[
"id": postId
]
]
}
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 200) { result in
switch result {
case .success(_):
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
/// Unpins a post from a collection.
///
/// Removes the post from a navigation item and puts it back on the blog itself. While the API endpoint can take an
/// array of posts, this function only accepts a single post.
///
/// - Parameters:
/// - token: The access token of the user un-pinning the post from the collection.
/// - postId: The ID of the post to be un-pinned.
/// - collectionAlias: The alias of the collection to which the post should be un-pinned.
/// - completion: A handler for the `Bool` returned on success, or `Error` on failure.
public func unpinPost(
token: String? = nil,
postId: String,
from collectionAlias: String,
completion: @escaping (Result<Bool, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections/\(collectionAlias)/unpin", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
let bodyObject: [[String: Any]] = [
[
"id": postId
]
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 200) { result in
switch result {
case .success(_):
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
/// Creates a new post.
///
/// Creates a new post. If a `collectionAlias` is provided, the post is published to that collection; otherwise, it
/// is posted to the user's Drafts.
///
/// - Parameters:
/// - token: The access token of the user creating the post.
/// - post: The `WFPost` object to be published.
/// - collectionAlias: The collection to which the post should be published.
/// - completion: A handler for the `WFPost` object returned on success, or `Error` on failure.
public func createPost(
token: String? = nil,
post: WFPost,
in collectionAlias: String? = nil,
completion: @escaping (Result<WFPost, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
var path = ""
if let alias = collectionAlias {
// TODO: Check here that the collection alias exists.
path = "collections/\(alias)/posts"
} else {
path = "posts"
}
guard let url = URL(string: path, relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
var createdDateString = ""
if let createdDate = post.createdDate {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = .withInternetDateTime
createdDateString = dateFormatter.string(from: createdDate)
}
let bodyObject: [String: Any] = [
"body": post.body,
"title": post.title ?? "",
"font": post.appearance ?? "",
"lang": post.language ?? "",
"rtl": post.rtl ?? false,
"created": createdDateString
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
self.post(with: request, expecting: 201) { result in
switch result {
case .success(let data):
do {
let post = try self.decoder.decode(ServerData<WFPost>.self, from: data)
completion(.success(post.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Retrieves a post.
///
/// The `WFPost` object returned may include additional data, including page views and extracted tags.
///
/// - Parameters:
/// - token: The access token of the user retrieving the post.
/// - postId: The ID of the post to be retrieved.
/// - completion: A handler for the `WFPost` object returned on success, or `Error` on failure.
public func getPost(
token: String? = nil,
byId postId: String,
completion: @escaping (Result<WFPost, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "posts/\(postId)", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
do {
let post = try self.decoder.decode(ServerData<WFPost>.self, from: data)
completion(.success(post.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Retrieves a post from a collection.
///
/// Collection posts can be retrieved without authentication. However, authentication is required for retrieving a
/// post from a private collection.
///
/// The `WFPost` object returned may include additional data, including page views and extracted tags.
///
/// - Parameters:
/// - token: The access token of the user retrieving the post.
/// - slug: The slug of the post to be retrieved.
/// - collectionAlias: The alias of the collection from which the post should be retrieved.
/// - completion: A handler for the `WFPost` object returned on success, or `Error` on failure.
public func getPost(
token: String? = nil,
bySlug slug: String,
from collectionAlias: String,
completion: @escaping (Result<WFPost, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "collections/\(collectionAlias)/posts/\(slug)", relativeTo: requestURL) else {
return
}
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
do {
let post = try self.decoder.decode(ServerData<WFPost>.self, from: data)
completion(.success(post.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Updates an existing post.
///
/// Note that if the `updatedPost` object is provided without a title, the original post's title will be removed.
///
/// - Attention: INCOMPLETE IMPLEMENTATION
/// - The modifyToken for the post is currently ignored.
///
/// - Parameters:
/// - token: The access token for the user updating the post.
/// - postId: The ID of the post to be updated.
/// - updatedPost: The `WFPost` object with which to update the existing post.
/// - modifyToken: The post's modify token; required if the post doesn't belong to the requesting user.
/// - completion: A handler for the `WFPost` object returned on success, or `Error` on failure.
public func updatePost(
token: String? = nil,
postId: String,
updatedPost: WFPost,
with modifyToken: String? = nil,
completion: @escaping (Result<WFPost, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "posts/\(postId)", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
let bodyObject: [String: Any] = [
"body": updatedPost.body,
"title": updatedPost.title ?? "",
"font": updatedPost.appearance ?? "",
"lang": updatedPost.language ?? "",
"rtl": updatedPost.rtl ?? false
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 200) { result in
switch result {
case .success(let data):
do {
let post = try self.decoder.decode(ServerData<WFPost>.self, from: data)
completion(.success(post.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Deletes an existing post.
///
/// - Attention: INCOMPLETE IMPLEMENTATION
/// - The modifyToken for the post is currently ignored.
///
/// - Parameters:
/// - token: The access token for the user deleting the post.
/// - postId: The ID of the post to be deleted.
/// - modifyToken: The post's modify token; required if the post doesn't belong to the requesting user.
/// - completion: A handler for the `Bool` object returned on success, or `Error` on failure.
public func deletePost(
token: String? = nil,
postId: String,
with modifyToken: String? = nil,
completion: @escaping (Result<Bool, Error>) -> Void
) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "posts/\(postId)", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
delete(with: request) { result in
switch result {
case .success(_):
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
/* Placeholder method stub: API design for this feature is not yet finalized.
func unpublishPost() {}
*/
/* Placeholder method stub: this feature is not yet implemented (Write.as feature only).
func claimPost() {}
*/
// MARK: - User-related methods
/// Logs the user in to their account on the WriteFreely instance.
///
/// On successful login, the `WFClient`'s `user` property is set to the returned `WFUser` object; this allows
/// authenticated requests to be made without having to provide an access token.
///
/// It is otherwise not necessary to login the user if their access token is provided to the calling function.
///
/// - Parameters:
/// - username: The user's username.
/// - password: The user's password.
/// - completion: A handler for the `WFUser` object returned on success, or `Error` on failure.
public func login(username: String, password: String, completion: @escaping (Result<WFUser, Error>) -> Void) {
guard let url = URL(string: "auth/login", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
let bodyObject: [String: Any] = [
"alias": username,
"pass": password
]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyObject, options: [])
} catch {
completion(.failure(error))
}
post(with: request, expecting: 200) { result in
switch result {
case .success(let data):
do {
let user = try self.decoder.decode(WFUser.self, from: data)
self.user = user
completion(.success(user))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
/// Invalidates the user's access token.
///
/// - Parameters:
/// - token: The token to invalidate.
/// - completion: A handler for the `Bool` object returned on success, or `Error` on failure.
public func logout(token: String? = nil, completion: @escaping (Result<Bool, Error>) -> Void) {
if token == nil && user == nil { return }
guard let tokenToDelete = token ?? user?.token else { return }
guard let url = URL(string: "auth/me", relativeTo: requestURL) else { fatalError() }
var request = URLRequest(url: url)
request.httpMethod = "DELETE"
request.addValue(tokenToDelete, forHTTPHeaderField: "Authorization")
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
delete(with: request) { result in
switch result {
case .success(_):
self.user = nil
completion(.success(true))
case .failure(let error):
completion(.failure(error))
}
}
}
/// Retrieves a user's basic data.
///
/// - Parameters:
/// - token: The access token for the user to fetch.
/// - completion: A handler for the `Data` object returned on success, or `Error` on failure.
public func getUserData(token: String? = nil, completion: @escaping (Result<Data, Error>) -> Void) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "me", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
completion(.success(data))
case .failure(let error):
completion(.failure(error))
}
}
}
/// Retrieves a user's collections.
///
/// - Parameters:
/// - token: The access token for the user whose collections are to be retrieved.
/// - completion: A handler for the `[WFCollection]` object returned on success, or `Error` on failure.
public func getUserCollections(token: String? = nil, completion: @escaping (Result<[WFCollection], Error>) -> Void) {
if token == nil && user == nil { return }
guard let tokenToVerify = token ?? user?.token else { return }
guard let url = URL(string: "me/collections", relativeTo: requestURL) else { return }
var request = URLRequest(url: url)
request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.addValue(tokenToVerify, forHTTPHeaderField: "Authorization")
get(with: request) { result in
switch result {
case .success(let data):
do {
let collection = try self.decoder.decode(ServerData<[WFCollection]>.self, from: data)
completion(.success(collection.data))
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
}
private extension WFClient {
func translateWFError(fromServerResponse response: Data) -> WFError? {
do {
let error = try self.decoder.decode(ErrorMessage.self, from: response)
print("⛔️ \(error.message)")
return WFError(rawValue: error.code)
} catch {
print("⛔️ An unknown error occurred.")
return WFError.unknownError
}
}
}
// MARK: - Protocol conformance
extension URLSession: URLSessionProtocol {
public func dataTask(
with request: URLRequest,
completionHandler: @escaping DataTaskResult
) -> URLSessionDataTaskProtocol {
return dataTask(with: request, completionHandler: completionHandler) as URLSessionDataTask
}
}
extension URLSessionDataTask: URLSessionDataTaskProtocol {}