diff --git a/Sources/APIClient.swift b/Sources/APIClient.swift index df52178..9c945b9 100644 --- a/Sources/APIClient.swift +++ b/Sources/APIClient.swift @@ -46,6 +46,9 @@ open class APIClient: NSObject, URLSessionDataDelegate { ///Which API filter to use if none is specified. open var defaultFilter: String? + ///Whether to use a secure connection to communicate with the API. + open var useSSL: Bool = true + ///Which site to use if none is specified. open var defaultSite: String = "stackoverflow" @@ -91,11 +94,11 @@ open class APIClient: NSObject, URLSessionDataDelegate { /// ///- parameter request: The request to make, for example `users/{ids}/answers`. ///- parameter parameters: Parameters to be URLEncoded into the request. - open func performAPIRequest( + open func performAPIRequest( _ request: String, _ parameters: [String:String] = [:], backoffBehavior: BackoffBehavior = .wait - ) throws -> [Any] { + ) throws -> APIResponse { var params = parameters @@ -111,9 +114,12 @@ open class APIClient: NSObject, URLSessionDataDelegate { if params["site"] == nil { params["site"] = defaultSite } + else if params["site"] == "" { + params["site"] = nil + } //Build the URL. - var url = "https://api.stackexchange.com/2.2" + var url = "\(useSSL ? "http" : "https")://api.stackexchange.com/2.2" let prefixedRequest = (request.hasPrefix("/") ? request : "/" + request) url += prefixedRequest @@ -151,19 +157,22 @@ open class APIClient: NSObject, URLSessionDataDelegate { throw APIError.notDictionary(response: response) } - if let backoff = json["backoff"] as? Int { + let apiResponse = APIResponse(dictionary: json) + + if let backoff = apiResponse.backoff { backoffs[backoffName] = Date().addingTimeInterval(TimeInterval(backoff)) } cleanBackoffs() - guard json["error_id"] == nil, json["error_message"] == nil else { + guard apiResponse.error_id == nil, apiResponse.error_message == nil else { throw APIError.apiError(id: json["error_id"] as? Int, message: json["error_message"] as? String) } - maxQuota = (json["quota_max"] as? Int) ?? maxQuota - quota = (json["quota_remaining"] as? Int) ?? quota - return (json["items"] as? [Any]) ?? [] + maxQuota = apiResponse.quota_max ?? maxQuota + quota = apiResponse.quota_remaining ?? quota + + return apiResponse } internal func wait(until date: Date) { diff --git a/Sources/APIResponse.swift b/Sources/APIResponse.swift new file mode 100644 index 0000000..9ae9658 --- /dev/null +++ b/Sources/APIResponse.swift @@ -0,0 +1,123 @@ +// +// APIResponse.swift +// SwiftStack +// +// Created by FelixSFD on 11.12.16. +// +// + +import Foundation + +/** + The common wrapper object that is returned by the StackExchange API. + + - authors: FelixSFD, NobodyNada + + - seealso: [StackExchange API](https://api.stackexchange.com/docs/wrapper) + */ +public class APIResponse: JsonConvertible { + + // - MARK: Items + + /** + The items that are returned of the generic type `T`. + + - note: It's always an array. Even if only a single item was requested. + + - author: FelixSFD + */ + public var items: [T]? + + + // - MARK: Initializers + + /** + Basic initializer without default values + */ + public init() { + + } + + public required convenience init?(jsonString json: String) { + do { + guard let dictionary = try JSONSerialization.jsonObject(with: json.data(using: String.Encoding.utf8)!) as? [String: Any] else { + return nil + } + + self.init(dictionary: dictionary) + } catch { + return nil + } + } + + public required init(dictionary: [String: Any]) { + self.backoff = dictionary["backoff"] as? Int + self.error_id = dictionary["error_id"] as? Int + self.error_message = dictionary["error_message"] as? String + self.error_name = dictionary["error_name"] as? String + self.has_more = dictionary["has_more"] as? Bool + self.page = dictionary["page"] as? Int + self.page_size = dictionary["page_size"] as? Int + self.quota_remaining = dictionary["quota_remaining"] as? Int + self.quota_max = dictionary["quota_max"] as? Int + self.total = dictionary["total"] as? Int + self.type = dictionary["type"] as? String + + + //items + if let array = dictionary["items"] as? [[String: Any]] { + items = array.map { T(dictionary: $0) } + } + + } + + // - MARK: JsonConvertible + + public var dictionary: [String: Any] { + var dict = [String: Any]() + + dict["backoff"] = backoff + dict["error_id"] = error_id + dict["error_message"] = error_message + dict["error_name"] = error_name + dict["has_more"] = has_more + dict["page"] = page + dict["page_size"] = page_size + dict["quota_max"] = quota_max + dict["quota_remaining"] = quota_remaining + dict["total"] = total + dict["type"] = type + + return dict + } + + public var jsonString: String? { + return (try? JsonHelper.jsonString(from: self)) ?? nil + } + + + // - MARK: Fields + + public var backoff: Int? + + public var error_id: Int? + + public var error_message: String? + + public var error_name: String? + + public var has_more: Bool? + + public var page: Int? + + public var page_size: Int? + + public var quota_max: Int? + + public var quota_remaining: Int? + + public var total: Int? + + public var type: String? + +} diff --git a/Sources/BadgeCount.swift b/Sources/BadgeCount.swift index 5ff0a65..d05722c 100644 --- a/Sources/BadgeCount.swift +++ b/Sources/BadgeCount.swift @@ -86,13 +86,9 @@ public struct BadgeCount: JsonConvertible, CustomStringConvertible { - author: FelixSFD */ - public init?(dictionary: [String: Any]) { + public init(dictionary: [String: Any]) { self.bronze = dictionary["bronze"] as? Int self.silver = dictionary["silver"] as? Int self.gold = dictionary["gold"] as? Int - - if bronze == nil, silver == nil, gold == nil { - return nil - } } } diff --git a/Sources/DictionaryConvertible.swift b/Sources/DictionaryConvertible.swift index ead50c5..769668d 100644 --- a/Sources/DictionaryConvertible.swift +++ b/Sources/DictionaryConvertible.swift @@ -17,7 +17,7 @@ public protocol DictionaryConvertible { /** Initializes the object from a dictionary */ - init?(dictionary: [String: Any]) + init(dictionary: [String: Any]) /** Returns the dictionary-representation of the object diff --git a/Sources/Question.swift b/Sources/Question.swift index 3f13d4a..975badb 100644 --- a/Sources/Question.swift +++ b/Sources/Question.swift @@ -129,7 +129,7 @@ public class Question: Post { } } - public init?(dictionary: [String : Any]) { + public init(dictionary: [String : Any]) { if let timestamp = dictionary["on_date"] as? Double { self.on_date = Date(timeIntervalSince1970: timestamp) } diff --git a/Sources/RequestsSites.swift b/Sources/RequestsSites.swift new file mode 100644 index 0000000..7d67624 --- /dev/null +++ b/Sources/RequestsSites.swift @@ -0,0 +1,70 @@ +// +// RequestsSites.swift +// SwiftStack +// +// Created by FelixSFD on 11.12.16. +// +// + +import Foundation +import Dispatch + +/** +This extension contains all requests in the SITES section of the StackExchange API Documentation. + +- authors: FelixSFD, NobodyNada +*/ +public extension APIClient { + + // - MARK: /sites + /** + Fetches all `Sites` in the Stack Exchange network synchronously. + + - parameter parameters: The dictionary of parameters used in the request + + - parameter backoffBehavior: The behavior when an APIRequest has a backoff + + - returns: The list of sites as `APIResponse` + + - author: NobodyNada + */ + public func fetchSites( + _ parameters: [String:String] = [:], + backoffBehavior: BackoffBehavior = .wait) throws -> APIResponse { + + + var params = parameters + params["site"] = "" + + return try performAPIRequest( + "sites", + params, + backoffBehavior: backoffBehavior + ) + } + + /** + Fetches all `Sites` in the Stack Exchange network asynchronously. + + - parameter parameters: The dictionary of parameters used in the request + + - parameter backoffBehavior: The behavior when an APIRequest has a backoff + + - author: FelixSFD + */ + public func fetchSites(_ parameters: [String: String] = [:], backoffBehavior: BackoffBehavior = .wait, completionHandler: @escaping (APIResponse?, Error?) -> ()) { + + queue.async { + var params = parameters + params["site"] = "" + + do { + let response: APIResponse = try self.performAPIRequest("sites", params, backoffBehavior: backoffBehavior) + completionHandler(response, nil) + } catch { + completionHandler(nil, error) + } + } + } + +} diff --git a/Sources/Site.swift b/Sources/Site.swift index f50d83e..ef049f8 100644 --- a/Sources/Site.swift +++ b/Sources/Site.swift @@ -37,7 +37,7 @@ public class Site: JsonConvertible { // - MARK: Initializers - public init?(dictionary: [String : Any]) { + public init(dictionary: [String : Any]) { self.api_site_parameter = dictionary["api_site_parameter"] as? String self.name = dictionary["name"] as? String @@ -140,7 +140,7 @@ public class Site: JsonConvertible { // - MARK: Initializers - public init?(dictionary: [String : Any]) { + public init(dictionary: [String : Any]) { self.link_color = dictionary["link_color"] as? String self.tag_background_color = dictionary["tag_background_color"] as? String self.tag_foreground_color = dictionary["tag_foreground_color"] as? String @@ -203,7 +203,7 @@ public class Site: JsonConvertible { } - public required init?(dictionary: [String : Any]) { + public required init(dictionary: [String : Any]) { self.aliases = dictionary["aliases"] as? [String] self.api_site_parameter = dictionary["api_site_parameter"] as? String self.audience = dictionary["audience"] as? String @@ -243,9 +243,7 @@ public class Site: JsonConvertible { var relatedArray = [Related]() for related in relatedSites { - if let tmp = Related(dictionary: related) { - relatedArray.append(tmp) - } + relatedArray.append(Related(dictionary: related)) } if relatedArray.count > 0 { diff --git a/Sources/User.swift b/Sources/User.swift index 8807b48..191dbbb 100644 --- a/Sources/User.swift +++ b/Sources/User.swift @@ -109,9 +109,7 @@ public class User: JsonConvertible, CustomStringConvertible { self.answer_count = dictionary["answer_count"] as? Int if let badgeCounts = dictionary["badge_counts"] as? [String: Any] { - if let badges = BadgeCount(dictionary: badgeCounts) { - self.badge_counts = badges - } + self.badge_counts = BadgeCount(dictionary: badgeCounts) } if let creationTimestamp = dictionary["creation_date"] as? Double { diff --git a/Tests/SwiftStackTests/APITests.swift b/Tests/SwiftStackTests/APITests.swift index d131f21..aa90b8b 100644 --- a/Tests/SwiftStackTests/APITests.swift +++ b/Tests/SwiftStackTests/APITests.swift @@ -43,6 +43,8 @@ class TestableClient: APIClient { class APITests: XCTestCase { //MARK: - Helpers var client: TestableClient! + + var expectation: XCTestExpectation? override func setUp() { client = TestableClient() @@ -109,7 +111,7 @@ class APITests: XCTestCase { return ("{}".data(using: .utf8), self.blankResponse(task), nil) } - let _ = try client.performAPIRequest("info") + let _ = try client.performAPIRequest("info") as APIResponse } @@ -129,7 +131,7 @@ class APITests: XCTestCase { return ("{}".data(using: .utf8), self.blankResponse(task), nil) } - let _ = try client.performAPIRequest("info", ["page":"1"]) + let _ = try client.performAPIRequest("info", ["page":"1"]) as APIResponse } @@ -149,7 +151,8 @@ class APITests: XCTestCase { return ("{}".data(using: .utf8), self.blankResponse(task), nil) } - let _ = try client.performAPIRequest("info", parameters) + let _ = try client.performAPIRequest("info", parameters) as APIResponse + //not actually a Site, but Info isn't implemented yet. } @@ -164,7 +167,7 @@ class APITests: XCTestCase { return (responseJSON.data(using: .utf8)!, self.blankResponse(task), nil) } - let _ = try client.performAPIRequest("info") + let _ = try client.performAPIRequest("info") as APIResponse XCTAssert(client.quota == expectedQuota, "quota \"\(client.quota)\" is incorrect (should be \"\(expectedQuota)\")") @@ -184,7 +187,7 @@ class APITests: XCTestCase { return (responseJSON.data(using: .utf8), self.blankResponse(task), nil) } - let _ = try client.performAPIRequest("info") + let _ = try client.performAPIRequest("info") as APIResponse client.onRequest {task in return ("{}".data(using: .utf8), self.blankResponse(task), nil) @@ -207,7 +210,7 @@ class APITests: XCTestCase { func testThrowingBackoff() throws { try prepareBackoff() - XCTAssertThrowsError(try client.performAPIRequest("info", backoffBehavior: .throwError)) + XCTAssertThrowsError(try client.performAPIRequest("info", backoffBehavior: .throwError) as APIResponse) } func testWaitingBackoff() throws { @@ -225,7 +228,7 @@ class APITests: XCTestCase { } //test waiting backoff - let _ = try client.performAPIRequest("info", backoffBehavior: .wait) + let _ = try client.performAPIRequest("info", backoffBehavior: .wait) as APIResponse //make sure the backoff is cleaned up XCTAssertNil(client.backoffs["info"], "backoff was not cleaned up after waiting") @@ -235,7 +238,50 @@ class APITests: XCTestCase { try prepareBackoff() client.backoffs["info"] = Date.distantPast - let _ = try client.performAPIRequest("users/1") + let _ = try client.performAPIRequest("users/1") as APIResponse XCTAssertNil(client.backoffs["info"], "backoff was not cleaned up") } + + + // - MARK: Sites + + + func testFetchSitesSync() { + client.onRequest { task in + return ("{\"items\": [{\"name\": \"Test Site\"}]}".data(using: .utf8), self.blankResponse(task), nil) + } + //not working yet. Just to show how to use the test method + do { + let response = try client.fetchSites() + XCTAssertNotNil(response.items, "items is nil") + XCTAssertEqual(response.items?.first?.name, "Test Site", "name was incorrect") + + } catch { + print(error) + XCTFail("fetchSites threw an error") + } + } + + func testFetchSitesAsync() { + expectation = expectation(description: "Fetched sites") + + client.onRequest { task in + return ("{\"items\": [{\"name\": \"Test Site\"}]}".data(using: .utf8), self.blankResponse(task), nil) + } + + client.fetchSites([:], backoffBehavior: .wait) { + response, error in + if error != nil { + print(error!) + XCTFail("Sites not fetched") + return + } + + print(response?.items ?? "no items") + self.expectation?.fulfill() + } + + waitForExpectations(timeout: 30, handler: nil) + } + } diff --git a/Tests/SwiftStackTests/PostTests.swift b/Tests/SwiftStackTests/PostTests.swift index 244fb5d..90f887a 100644 --- a/Tests/SwiftStackTests/PostTests.swift +++ b/Tests/SwiftStackTests/PostTests.swift @@ -16,7 +16,7 @@ class PostTests: XCTestCase { let question = Question(jsonString: json) let jsonQuestion = question?.jsonString - print(jsonQuestion) + print(jsonQuestion ?? "nil") XCTAssertNotNil(jsonQuestion) } @@ -25,7 +25,7 @@ class PostTests: XCTestCase { let answer = Answer(jsonString: json) let jsonAnswer = answer?.jsonString - print(jsonAnswer) + print(jsonAnswer ?? "nil") XCTAssertNotNil(jsonAnswer) } diff --git a/Tests/SwiftStackTests/SiteTests.swift b/Tests/SwiftStackTests/SiteTests.swift index 4ada13b..0152c3f 100644 --- a/Tests/SwiftStackTests/SiteTests.swift +++ b/Tests/SwiftStackTests/SiteTests.swift @@ -11,12 +11,16 @@ import SwiftStack class SiteTests: XCTestCase { + var client = TestableClient() + + var expectation: XCTestExpectation? + func testMainSite() { let json = "{\"styling\": {\"tag_background_color\": \"#E0EAF1\",\"tag_foreground_color\": \"#3E6D8E\",\"link_color\": \"#0077CC\"},\"related_sites\": [{\"relation\": \"meta\",\"api_site_parameter\": \"meta.example\",\"site_url\": \"http://meta.example.stackexchange.com/\",\"name\": \"Meta Example Site\"}],\"launch_date\": 1481360679,\"open_beta_date\": 1481274279,\"closed_beta_date\": 1481187879,\"site_state\": \"normal\",\"twitter_account\": \"@StackExchange\",\"favicon_url\": \"http://sstatic.net/stackexchange/img/favicon.ico\",\"icon_url\": \"http://sstatic.net/stackexchange/img/apple-touch-icon.png\",\"audience\": \"example lovers\",\"site_url\": \"http://example.stackexchange.com\",\"api_site_parameter\": \"example\",\"logo_url\": \"http://sstatic.net/stackexchange/img/logo.png\",\"name\": \"Example Site\",\"site_type\": \"main_site\"}" let site = Site(jsonString: json) let jsonSite = site?.jsonString - print(jsonSite) + print(jsonSite ?? "nil") XCTAssertNotNil(jsonSite) }