Kinvey/swift-sdk

View on GitHub
Kinvey/Kinvey/HttpRequest.swift

Summary

Maintainability
D
2 days
Test Coverage
//
//  HttpRequest.swift
//  Kinvey
//
//  Created by Victor Barros on 2016-01-15.
//  Copyright © 2016 Kinvey. All rights reserved.
//

import Foundation

#if os(iOS) || os(tvOS)
    import UIKit
#elseif os(watchOS)
    import WatchKit
#endif

enum Encoding: String {

    case utf8 = "utf-8"

}

enum MimeType: String {

    case applicationJson = "application/json"
    case applicationXWWWFormURLEncoded = "application/x-www-form-urlencoded"

}

struct MimeTypeCharset {

    let mimeType: MimeType
    let encoding: Encoding

    init(_ mimeType: MimeType) {
        self.init(mimeType: mimeType)
    }

    init(mimeType: MimeType, encoding: Encoding = .utf8) {
        self.mimeType = mimeType
        self.encoding = encoding
    }

    var value: String {
        return "\(mimeType.rawValue); charset=\(encoding.rawValue)"
    }

}

enum HeaderField: String {

    case userAgent = "User-Agent"
    case contentType = "Content-Type"

}

enum KinveyHeaderField: String {

    case requestId = "X-Kinvey-Request-Id"
    case clientAppVersion = "X-Kinvey-Client-App-Version"
    case customRequestProperties = "X-Kinvey-Custom-Request-Properties"
    case apiVersion = "X-Kinvey-API-Version"
    case deviceInfo = "X-Kinvey-Device-Info"

}

extension Dictionary where Key == AnyHashable {

    subscript<K: RawRepresentable>(key: K) -> Value? where K.RawValue: Hashable {
        return self[key.rawValue]
    }

}

enum HttpMethod {

    case get, post, put, delete

    var stringValue: String {
        switch self {
        case .post:
            return "POST"
        case .put:
            return "PUT"
        case .delete:
            return "DELETE"
        default:
            return "GET"
        }
    }

    static func parse(_ httpMethod: String) -> HttpMethod {
        switch httpMethod {
        case "POST":
            return .post
        case "PUT":
            return .put
        case "DELETE":
            return .delete
        default:
            return .get
        }
    }

    var requestType: RequestType {
        switch self {
        case .post:
            return .create
        case .put:
            return .update
        case .delete:
            return .delete
        default:
            return .read
        }
    }

}

enum HttpHeaderKey: String {

    case authorization = "Authorization"

}

enum HttpHeader {

    case authorization(credential: Credential?)
    case apiVersion(version: Int)
    case requestId(requestId: String)
    case userAgent
    case deviceInfo

    var name: String {
        switch self {
        case .authorization:
            return HttpHeaderKey.authorization.rawValue
        case .apiVersion:
            return KinveyHeaderField.apiVersion.rawValue
        case .requestId:
            return KinveyHeaderField.requestId.rawValue
        case .userAgent:
            return HeaderField.userAgent.rawValue
        case .deviceInfo:
            return KinveyHeaderField.deviceInfo.rawValue
        }
    }

    var value: String? {
        switch self {
        case .authorization(let credential):
            return credential?.authorizationHeader
        case .apiVersion(let version):
            return String(version)
        case .requestId(let requestId):
            return requestId
        case .userAgent:
            return "Kinvey SDK \(Bundle(for: Client.self).infoDictionary?["CFBundleShortVersionString"] ?? "") (Swift \(swiftVersion))"
        case .deviceInfo:
            let data = try! jsonEncoder.encode(DeviceInfo())
            return String(data: data, encoding: .utf8)
        }
    }

}

extension RequestType {

    var httpMethod: HttpMethod {
        switch self {
        case .create:
            return .post
        case .read:
            return .get
        case .update:
            return .put
        case .delete:
            return .delete
        }
    }

}

internal typealias DataResponseCompletionHandler = (Data?, Response?, Swift.Error?) -> Void
internal typealias PathResponseCompletionHandler = (URL?, Response?, Swift.Error?) -> Void

extension URLRequest {

    /// Description for the NSURLRequest including url, headers and the body content
    public var description: String {
        var description = "\(httpMethod ?? "GET") \(url?.absoluteString ?? "")"
        if let headers = allHTTPHeaderFields {
            for (headerField, value) in headers {
                description += "\n\(headerField): \(value)"
            }
        }
        if let body = httpBody, let bodyString = String(data: body, encoding: String.Encoding.utf8) {
            description += "\n\n\(bodyString)"
        }
        return description
    }

    mutating func setValue<Header: RawRepresentable>(_ value: String?, forHTTPHeaderField header: Header) where Header.RawValue == String {
        setValue(value, forHTTPHeaderField: header.rawValue)
    }

    mutating func addValue<Header: RawRepresentable>(_ value: String, forHTTPHeaderField header: Header) where Header.RawValue == String {
        addValue(value, forHTTPHeaderField: header.rawValue)
    }

    mutating func setValue<Header: RawRepresentable, Value: RawRepresentable>(_ value: Value?, forHTTPHeaderField header: Header) where Header.RawValue == String, Value.RawValue == String {
        setValue(value?.rawValue, forHTTPHeaderField: header.rawValue)
    }

    mutating func addValue<Header: RawRepresentable, Value: RawRepresentable>(_ value: Value, forHTTPHeaderField header: Header) where Header.RawValue == String, Value.RawValue == String {
        addValue(value.rawValue, forHTTPHeaderField: header.rawValue)
    }

    mutating func setValue(_ mimeType: MimeTypeCharset?) {
        setValue(mimeType?.value, forHTTPHeaderField: HeaderField.contentType)
    }

    mutating func addValue(_ mimeType: MimeTypeCharset) {
        addValue(mimeType.value, forHTTPHeaderField: HeaderField.contentType)
    }

    mutating func setBody(json: JsonDictionary) throws {
        setValue(MimeTypeCharset(.applicationJson))
        httpBody = try JSONSerialize.data(json)
    }

    mutating func setBody(json: [JsonDictionary]) throws {
        setValue(MimeTypeCharset(.applicationJson))
        httpBody = try JSONSerialize.data(json)
    }

    mutating func setBody(xWWWFormURLEncoded: [String : String]) {
        setValue(MimeTypeCharset(.applicationXWWWFormURLEncoded))
        var paramsKeyValue = [String]()
        for (key, value) in xWWWFormURLEncoded {
            if let key = key.stringByAddingPercentEncodingForFormData(),
                let value = value.stringByAddingPercentEncodingForFormData()
            {
                paramsKeyValue.append("\(key)=\(value)")
            }
        }
        let httpBody = paramsKeyValue.joined(separator: "&")
        self.httpBody = httpBody.data(using: .utf8)
    }

    func value<Header: RawRepresentable>(forHTTPHeaderField header: Header) -> String? where Header.RawValue == String {
        return value(forHTTPHeaderField: header.rawValue)
    }

}

extension HTTPURLResponse {

    /// Description for the NSHTTPURLResponse including url and headers
    open override var description: String {
        var description = "\(statusCode) \(HTTPURLResponse.localizedString(forStatusCode: statusCode))"
        for (headerField, value) in allHeaderFields {
            description += "\n\(headerField): \(value)"
        }
        return description
    }

    /// Description for the NSHTTPURLResponse including url, headers and the body content
    public func description(_ body: Data?) -> String {
        var description = self.description
        if let body = body, let bodyString = String(data: body, encoding: String.Encoding.utf8) {
            description += "\n\n\(bodyString)"
        }
        description += "\n"
        return description
    }

}

extension String {

    internal func stringByAddingPercentEncodingForFormData(plusForSpace: Bool = false) -> String? {
        var allowed = CharacterSet.alphanumerics
        allowed.insert(charactersIn: "*-._")

        if plusForSpace {
            allowed.insert(charactersIn: " ")
        }

        var encoded = addingPercentEncoding(withAllowedCharacters: allowed)
        if plusForSpace {
            encoded = encoded?.replacingOccurrences(of: " ", with: "+")
        }
        return encoded
    }

}

/// Default REST API Version used in the REST calls.
public let defaultRestApiVersion = 5

/// REST API Version used in the REST calls.
public var restApiVersion = defaultRestApiVersion

enum Body {

    case json(json: JsonDictionary)
    case jsonArray(json: [JsonDictionary])
    case formUrlEncoded(params: [String : String])

    func attachTo(request: inout URLRequest) {
        switch self {
        case .json(let json):
            try! request.setBody(json: json)
        case .jsonArray(let jsonArray):
            try! request.setBody(json: jsonArray)
        case .formUrlEncoded(let params):
            request.setBody(xWWWFormURLEncoded: params)
        }
    }

    static func buildFormUrlEncoded(body: String) -> Body {
        let regex = try! NSRegularExpression(pattern: "([^&=]+)=([^&]*)")
        var params = [String : String]()
        let nsbody = body as NSString
        for match in regex.matches(in: body, range: NSRange(location: 0, length: body.count)) where match.numberOfRanges == 3 {
            let key = nsbody.substring(with: match.range(at: 1))
            let value = nsbody.substring(with: match.range(at: 2))
            params[key] = value
        }
        return .formUrlEncoded(params: params)
    }

}

internal class HttpRequest<Result>: TaskProgressRequest, Request {

    typealias ResultType = Result

    var result: Result?

    let httpMethod: HttpMethod
    let endpoint: Endpoint
    let defaultHeaders: [HttpHeader] = [
        HttpHeader.apiVersion(version: restApiVersion),
        HttpHeader.userAgent,
        HttpHeader.deviceInfo
    ]

    var headers: [HttpHeader] = []

    var request: URLRequest
    var credential: Credential? {
        didSet {
            setAuthorization()
        }
    }
    let options: Options?
    let client: Client

    internal var executing: Bool {
        return task?.state == .running
    }

    internal var cancelled: Bool {
        return task?.state == .canceling || (task?.error as NSError?)?.code == NSURLErrorCancelled
    }

    init(
        request: URLRequest,
        options: Options?
    ) {
        self.httpMethod = HttpMethod.parse(request.httpMethod!)
        self.endpoint = URLEndpoint(url: request.url!)
        self.options = options
        let client = options?.client ?? sharedClient
        self.client = client

        if request.value(forHTTPHeaderField: HttpHeaderKey.authorization.rawValue) == nil {
            self.credential = client.activeUser ?? client
        }
        self.request = request
        if let timeout = options?.timeout {
            self.request.timeoutInterval = timeout
        }
        self.request.setValue(UUID().uuidString, forHTTPHeaderField: KinveyHeaderField.requestId)
    }

    init(
        httpMethod: HttpMethod = .get,
        endpoint: Endpoint,
        credential: Credential? = nil,
        body: Body? = nil,
        options: Options?
    ) {
        self.httpMethod = httpMethod
        self.endpoint = endpoint
        self.options = options
        let client = options?.client ?? sharedClient
        self.client = client
        self.credential = credential ?? client

        let url = endpoint.url
        request = URLRequest(url: url)
        request.httpMethod = httpMethod.stringValue
        if let timeout = options?.timeout ?? client.options?.timeout {
            request.timeoutInterval = timeout
        }
        if let body = body {
            body.attachTo(request: &request)
        }
        self.request.setValue(UUID().uuidString, forHTTPHeaderField: KinveyHeaderField.requestId)
    }

    private func setAuthorization() {
        if let credential = credential {
            let header = HttpHeader.authorization(credential: credential)
            request.setValue(header.value, forHTTPHeaderField: header.name)
        }
    }

    func prepareRequest() {
        for header in defaultHeaders {
            request.setValue(header.value, forHTTPHeaderField: header.name)
        }
        setAuthorization()
        for header in headers {
            request.setValue(header.value, forHTTPHeaderField: header.name)
        }
        if let clientAppVersion = options?.clientAppVersion ?? client.options?.clientAppVersion {
            request.setValue(clientAppVersion, forHTTPHeaderField: KinveyHeaderField.clientAppVersion)
        }

        if let customRequestProperties = self.options?.customRequestProperties ?? client.options?.customRequestProperties,
            customRequestProperties.count > 0,
            let data = try? JSONSerialize.data(customRequestProperties),
            let customRequestPropertiesString = String(data: data, encoding: .utf8)
        {
            request.setValue(customRequestPropertiesString, forHTTPHeaderField: KinveyHeaderField.customRequestProperties)
        }

        if let url = request.url,
            let query = url.query,
            query.contains("+"),
            let decodedQuery = query.removingPercentEncoding,
            let newQuery = decodedQuery.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+"))),
            var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false)
        {
            urlComponents.percentEncodedQuery = newQuery
            request.url = urlComponents.url
        }
    }

    func execute(urlSession: URLSession? = nil, _ completionHandler: DataResponseCompletionHandler? = nil) {
        execute(retry: true, urlSession: urlSession, completionHandler)
    }

    func execute(retry: Bool, urlSession: URLSession?, _ completionHandler: DataResponseCompletionHandler?) {
        guard !cancelled else {
            completionHandler?(nil, nil, Error.requestCancelled)
            return
        }

        prepareRequest()

        if client.logNetworkEnabled {
            do {
                log.debug("\(request.description)")
            }
        }

        let urlSession = urlSession ?? options?.urlSession ?? client.urlSession
        task = urlSession.dataTask(with: request) { (data, response, error) -> Void in
            self.handleResponse(
                retry: retry,
                urlSession: urlSession,
                data: data,
                response: response,
                error: error,
                completionHandler: completionHandler
            )
        }
        task!.resume()
    }

    private func handleResponse(
        retry: Bool,
        urlSession: URLSession,
        data: Data?,
        response: URLResponse?,
        error: Swift.Error?,
        completionHandler: DataResponseCompletionHandler?
    ) {
        if let response = response as? HTTPURLResponse {
            if client.logNetworkEnabled {
                do {
                    log.debug("\(response.description(data))")
                }
            }
            if response.statusCode == 401,
                retry,
                let user = credential as? User,
                let data = data,
                let json = try? client.jsonParser.parseDictionary(from: data) as? [String : String],
                json["error"] != Error.Keys.insufficientCredentials.rawValue
            {
                DispatchQueue.global(qos: .default).async {
                    self.refreshToken(
                        user: user,
                        urlSession: urlSession,
                        data: data,
                        response: response,
                        error: error,
                        completionHandler: completionHandler
                    )
                }
                return
            }
        }

        completionHandler?(data, HttpResponse(response: response), error)
    }

    private func refreshToken(
        user: User,
        urlSession: URLSession,
        data: Data?,
        response: URLResponse?,
        error: Swift.Error?,
        completionHandler: DataResponseCompletionHandler?
    ) {
        guard !client.refreshingToken else {
            log.debug("Waiting to refresh token. Request: \(request.url!)")
            client.refreshTokenDispatchGroup.wait()
            if let newUser = client.activeUser {
                log.debug("Retrying request after refresh token. Request: \(request.url!)")
                credential = newUser
                execute(retry: false, urlSession: urlSession, completionHandler)
            } else {
                log.debug("Refresh token failed. Logging out current user. Request: \(request.url!)")
                user.logout()
                completionHandler?(data, HttpResponse(response: response), error)
            }
            return
        }
        client.refreshingToken = true
        client.refreshTokenDispatchGroup.enter()
        guard let refreshToken = user.refreshToken else {
            log.debug("Refresh token not available. Logging out current user. Request: \(request.url!)")
            user.logout()
            completionHandler?(data, HttpResponse(response: response), error)
            self.client.refreshingToken = false
            client.refreshTokenDispatchGroup.leave()
            return
        }
        log.debug("Refreshing token. Request: \(request.url!)")
        let options = try! Options(self.options, authServiceId: client.clientId)
        MIC.login(refreshToken: refreshToken, options: options) {
            switch $0 {
            case .success(let user):
                self.credential = user
                log.debug("Retrying request after refresh token. Request: \(self.request.url!)")
                self.execute(retry: false, urlSession: urlSession, completionHandler)
            case .failure(let error):
                if let error = error as? Kinvey.Error {
                    switch error {
                    case .invalidCredentials:
                        if let user = self.credential as? User {
                            log.debug("Logging out current user. Request: \(self.request.url!)")
                            user.logout()
                        }
                    default:
                        break
                    }
                }
                log.debug("Refresh token failed. Request: \(self.request.url!)")
                completionHandler?(data, HttpResponse(response: response), error)
            }
            self.client.refreshingToken = false
            self.client.refreshTokenDispatchGroup.leave()
        }
    }

    internal func cancel() {
        task?.cancel()
    }

    var curlCommand: String {
        prepareRequest()

        var headers = ""
        if let allHTTPHeaderFields = request.allHTTPHeaderFields {
            for (headerField, value) in allHTTPHeaderFields {
                headers += "-H \"\(headerField): \(value)\" "
            }
        }
        return "curl -X \(String(describing: request.httpMethod)) \(headers) \(request.url!)"
    }

    func setBody(json: [String : Any]) {
        try! request.setBody(json: json)
    }

    func setBody(json: [[String : Any]]) {
        try! request.setBody(json: json)
    }

}