From 41769a99e8441134d2c19eca74eb48daa77a1699 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Thu, 24 Aug 2017 12:09:39 -0700 Subject: [PATCH] add some more HTTP utilities --- FormEncoder.swift | 80 +++++++++++++++++++ HTTP.swift | 145 +++++++++++++++++++++++++++++++++++ Readme.md | 89 +++++++++++++++++++-- RequestBuilder.swift | 52 +++++++++++++ Service.swift | 179 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 FormEncoder.swift create mode 100644 HTTP.swift create mode 100644 RequestBuilder.swift create mode 100644 Service.swift diff --git a/FormEncoder.swift b/FormEncoder.swift new file mode 100644 index 0000000..0a3db16 --- /dev/null +++ b/FormEncoder.swift @@ -0,0 +1,80 @@ +// +// Lifted from Alamofire (ParameterEncoding.swift): https://github.com/Alamofire/Alamofire +// + +import Foundation + +final class FormEncoder { + class func encode(_ parameters: [String: Any]) -> String { + var components: [(String, String)] = [] + + for key in parameters.keys.sorted(by: <) { + let value = parameters[key]! + components += pairs(from: key, value: value) + } + return components.map { "\($0)=\($1)" }.joined(separator: "&") + } + + /// Creates percent-escaped, URL encoded query string components from the given key-value pair using recursion. + /// + /// - parameter key: The key of the query component. + /// - parameter value: The value of the query component. + /// + /// - returns: The percent-escaped, URL encoded query string components. + static func pairs(from key: String, value: Any) -> [(String, String)] { + var components: [(String, String)] = [] + + if let dictionary = value as? [String: Any] { + for (nestedKey, value) in dictionary { + components += pairs(from: "\(key)[\(nestedKey)]", value: value) + } + } + else if let array = value as? [Any] { + for value in array { + components += pairs(from: "\(key)[]", value: value) + } + } + else if let value = value as? NSNumber { + if value.isBool { + components.append((escape(key), escape((value.boolValue ? "1" : "0")))) + } + else { + components.append((escape(key), escape("\(value)"))) + } + } + else if let bool = value as? Bool { + components.append((escape(key), escape((bool ? "1" : "0")))) + } + else { + components.append((escape(key), escape("\(value)"))) + } + + return components + } + + /// Returns a percent-escaped string following RFC 3986 for a query string key or value. + /// + /// RFC 3986 states that the following characters are "reserved" characters. + /// + /// - General Delimiters: ":", "#", "[", "]", "@", "?", "/" + /// - Sub-Delimiters: "!", "$", "&", "'", "(", ")", "*", "+", ",", ";", "=" + /// + /// In RFC 3986 - Section 3.4, it states that the "?" and "/" characters should not be escaped to allow + /// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/" + /// should be percent-escaped in the query string. + /// + /// - parameter string: The string to be percent-escaped. + /// + /// - returns: The percent-escaped string. + private static func escape(_ string: String) -> String { + let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 + let subDelimitersToEncode = "!$&'()*+,;=" + + var allowedCharacterSet = CharacterSet.urlQueryAllowed + allowedCharacterSet.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") + + // FIXME: should we fail instead of falling back the unescaped string here? probably... + let escaped = string.addingPercentEncoding(withAllowedCharacters: allowedCharacterSet) ?? string + return escaped + } +} diff --git a/HTTP.swift b/HTTP.swift new file mode 100644 index 0000000..da51d88 --- /dev/null +++ b/HTTP.swift @@ -0,0 +1,145 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +import Foundation + +enum HTTPMethod: String { + case delete + case get + case patch + case post + case put + + var string: String { + return rawValue.uppercased() + } +} + +enum HTTPContentType { + case formEncoded + case none + case json + case multipart +} + +final class HTTPRequest { + let method: HTTPMethod + let url: URL + private(set) var contentType: HTTPContentType + let parameters: [String : Any]? + private(set) var headers: [String : String] = [:] + private(set) var parts: [MultipartFormEncoder.Part] = [] + + init(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) { + self.method = method + self.url = url + self.contentType = contentType + self.parameters = parameters + } + + func addHeader(name: String, value: String) { + headers[name] = value + } + + func addMultipartJPEG(name: String, image: UIImage, quality: CGFloat, filename: String? = nil) { + guard let data = UIImageJPEGRepresentation(image, quality) else { + assertionFailure() + return + } + let part = MultipartFormEncoder.Part(name: name, type: "image/jpeg", encoding: "binary", data: data, filename: filename) + addPart(part) + } + + private func addPart(_ part: MultipartFormEncoder.Part) { + // Convert this request to multipart + if parts.isEmpty { + contentType = .multipart + } + parts.append(part) + } +} + +enum HTTPRequestError: Error { + case http + case unknown +} + +enum HTTPResponse { + case success(HTTPURLResponse, Data?) + case failure(Error, HTTPURLResponse, Data?) + + init(response maybeResponse: URLResponse?, data: Data?, error: Error?) { + guard let response = maybeResponse as? HTTPURLResponse else { + self = .failure(error ?? HTTPRequestError.unknown, HTTPURLResponse(), data) + return + } + + if let error = error { + self = .failure(error, response, data) + } + else if response.statusCode >= 200 && response.statusCode < 300 { + self = .success(response, data) + } + else { + self = .failure(HTTPRequestError.http, response, data) + } + } + + var data: Data? { + switch self { + case let .success(_, data): return data + case let .failure(_, _, data): return data + } + } + + var underlyingResponse: HTTPURLResponse { + switch self { + case let .success(response, _): return response + case let .failure(_, response, _): return response + } + } + + var status: Int { + return underlyingResponse.statusCode + } + + var headers: [AnyHashable : Any] { + return underlyingResponse.allHeaderFields + } + + var bodyString: String { + guard let data = self.data else { + log.warning("No data found on response: \(self)") + return "" + } + guard let string = String(data: data, encoding: .utf8) else { + log.warning("Data is not UTF8: \(data)") + return "" + } + return string + } + + var dictionaryFromJSON: [String : Any] { + guard let data = self.data else { + log.warning("No data found on response: \(self)") + return [:] + } + do { + guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] else { + if let parsed = try? JSONSerialization.jsonObject(with: data, options: []) { + log.error("Failed to parse JSON as dictionary: \(parsed)") + } + return [:] + } + return dictionary + } + catch { + let json = String(data: data, encoding: .utf8) ?? "" + log.error("Failed to parse JSON \(json): \(error)") + return [:] + } + } +} diff --git a/Readme.md b/Readme.md index 43c621b..53f973f 100644 --- a/Readme.md +++ b/Readme.md @@ -1,14 +1,13 @@ # Osiris -A multipart form encoder for Swift. +A multipart form encoder for Swift, as well as some other utilities that make +working with HTTP a bit simpler and more flexible. # Installation -Copy the file [`MultipartFormEncoder.swift`][code] into your project. +Copy the files you want to use into your project, and then customize them to suit your needs. -[code]: https://github.com/1SecondEveryday/Osiris/blob/master/MultipartFormEncoder.swift - -# Usage +# Multipart Form Encoding Create an encoder and then add parts to it as needed: @@ -50,9 +49,87 @@ task.resume() You can create and add your own parts using the `MultipartFormEncoder.Part` struct and `MultipartFormEncoder.addPart(_ part: Part)`. +# HTTPRequest + +Basic usage: + +```Swift +let url = URL(string: "https://example.com")! +let request = HTTPRequest(method: .get, url: url) +``` + +Fancier usage: + +```Swift +let url = URL(string: "https://example.com")! +let params = ["email" : "someone@example.com", "password" : "secret"] +let request = HTTPRequest(method: .post, url: url, contentType: .json, parameters: params) +request.addHeader(name: "x-custom", value: "42") +request.addMultipartJPEG(name: "avatar", image: UIImage(), quality: 1, filename: "avatar.jpg") +``` + +You can build a `URLRequest` from an `HTTPRequest` instance using `RequestBuilder`. Or make your own builder. + +# HTTPResponse + +This enum makes sense of the 3 parameters of `URLSession`'s completion block. Its initializer takes in the optional `URLResponse`, `Data`, and `Error` values and determines if the request succeeded or failed, taking the HTTP status code into account. 200-level statuses are successes and anything else is a failure. + +The success case has two associated values: `HTTPURLResponse` and `Data?`, while the failure case has three associated values: `Error`, `HTTPURLResponse`, and `Data?`. + +Some properties are exposed for convenience: + +- `data`: the optional body data returned by the server. + +- `status`: the HTTP status code returned by the server, or 0 if the request itself failed, e.g. if the server cannot be reached. + +- `headers`: a dictionary of headers. + +- `bodyString`: the response body as a `String`. This is an empty string if the body is empty or there was an error decoding it as UTF8. + +- `dictionaryFromJSON`: the decoded body for JSON responses. This is an empty dictionary if the body is empty or there was an error decoding it as a JSON dictionary. + +- `underlyingResponse`: the `HTTPURLResponse` in case you need to dive in. + +# RequestBuilder + +This class takes in an `HTTPRequest` instance and turns it into a `URLRequest` for use with `URLSession`. + +Usage: + +```Swift +let urlRequest: URLRequest +do { + urlRequest = try RequestBuilder.build(request: request) +} +catch { + log.error("Invalid request \(request): \(error)") + return +} +// ... do something with urlRequest +``` + +It encodes multipart requests in memory, so you'll need to change it or make your own builder for advanced functionality like encoding multipart forms to disk instead. + +# FormEncoder + +This was lifted from [Alamofire][], but with some minor changes. + +```Swift +let body = FormEncoder.encode(["email" : "someone@example.com", "password" : "secret"]) +// => "email=someone%40example.com&password=secret" +``` + +[Alamofire]: https://github.com/Alamofire/Alamofire + +# Service: Putting it all Together + +Take a look at `Service.swift` to see how it can all come together. Grafting your specific service API onto the primitives shown there is an exercise. In 1SE we're just adding methods to `Service` for each specific call, but you could keep them separate instead if you prefer that. + +I don't recommend you use `Service` as shown here, but maybe use it as a jumping off point for something that makes sense to you for your specific application. + # Credits -Created by Sami Samhuri for [1SE][]. +Mostly created by Sami Samhuri for [1SE][]. `FormEncoder.swift` was lifted from [Alamofire][]. [1SE]: http://1se.co diff --git a/RequestBuilder.swift b/RequestBuilder.swift new file mode 100644 index 0000000..72b1019 --- /dev/null +++ b/RequestBuilder.swift @@ -0,0 +1,52 @@ +// +// Created by Sami Samhuri on 2017-07-28. +// Copyright © 2017 1 Second Everyday. All rights reserved. +// Released under the terms of the MIT license. +// + +import Foundation + +enum RequestBuilderError: Error { + case invalidFormData(HTTPRequest) +} + +final class RequestBuilder { + class func build(request: HTTPRequest) throws -> URLRequest { + assert(!(request.method == .get && request.parameters != nil), "encoding GET params is not yet implemented") + var result = URLRequest(url: request.url) + result.httpMethod = request.method.string + for (name, value) in request.headers { + result.addValue(value, forHTTPHeaderField: name) + } + if let params = request.parameters { + let data: Data + switch request.contentType { + case .json: + result.addValue("application/json", forHTTPHeaderField: "Content-Type") + data = try JSONSerialization.data(withJSONObject: params, options: []) + + case .none: + // Fall back to form encoding for maximum compatibility. + assertionFailure("Cannot serialize parameters without a content type") + fallthrough + case .formEncoded: + result.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + guard let formData = FormEncoder.encode(params).data(using: .utf8) else { + throw RequestBuilderError.invalidFormData(request) + } + result.httpBody = formData + + case .multipart: + let encoder = MultipartFormEncoder() + for part in request.parts { + encoder.addPart(part) + } + let encoded = try encoder.encodeToMemory() + result.addValue(encoded.contentType, forHTTPHeaderField: "Content-Type") + result.addValue("\(encoded.contentLength)", forHTTPHeaderField: "Content-Length") + result.httpBody = encoded.body + } + } + return result + } +} diff --git a/Service.swift b/Service.swift new file mode 100644 index 0000000..7147999 --- /dev/null +++ b/Service.swift @@ -0,0 +1,179 @@ +// +// Created by Sami Samhuri on 2016-07-30. +// Copyright © 2016 1 Second Everyday. All rights reserved. +// + +import PromiseKit +import UIKit + +enum ServiceError: Error { + case malformedRequest(HTTPRequest) + case malformedResponse(message: String) +} + +enum ServiceEnvironment: String { + case production + case staging + case development + + private static let selectedEnvironmentKey = "ServiceEnvironment:SelectedEnvironment" + static var selected: ServiceEnvironment { + get { + guard let rawValue = UserDefaults.standard.string(forKey: selectedEnvironmentKey), + let selected = ServiceEnvironment(rawValue: rawValue) + else { + return .production + } + return selected + } + set { + assert(Thread.isMainThread) + guard newValue != selected else { + return + } + UserDefaults.standard.set(newValue.rawValue, forKey: selectedEnvironmentKey) + } + } + + var baseURL: URL { + switch self { + case .production: return URL(string: "https://example.com")! + case .staging: return URL(string: "https://staging.example.com")! + case .development: return URL(string: "https://dev.example.com")! + } + } +} + +final class Service { + fileprivate var token: String? + fileprivate var environment: ServiceEnvironment + fileprivate var urlSession: URLSession + + init(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { + self.environment = environment + self.urlSession = URLSession(configuration: .urlSessionConfig ?? .default) + super.init() + } + + func reconfigure(environment: ServiceEnvironment, urlSessionConfig: URLSessionConfiguration? = nil) { + self.environment = environment + self.urlSession = URLSession(configuration: urlSessionConfig ?? .default) + } + + // MARK: - Authentication + + func authenticate(token: String) { + self.token = token + } + + func deauthenticate() { + token = nil + } + + // MARK: - Your service calls here + + // For example... (you probably want a more specific result type unpacked from the response though) + func signUp(email: String, password: String, avatar: UIImage) -> Promise { + let parameters = ["email" : email, "password" : password] + let request = postRequest(path: "/accounts", parameters: parameters) + request.addMultipartJPEG(name: "avatar", image: avatar, quality: 1) + return performRequest(request) + } + + // MARK: - Requests + + fileprivate func deleteRequest(path: String, parameters: [String : Any]? = nil) -> HTTPRequest { + return newRequest(method: .delete, path: path, parameters: parameters) + } + + fileprivate func getRequest(path: String) -> HTTPRequest { + return newRequest(method: .get, path: path) + } + + fileprivate func patchRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + return newRequest(method: .patch, path: path, contentType: .formEncoded, parameters: parameters) + } + + fileprivate func postJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + return newRequest(method: .post, path: path, contentType: .json, parameters: parameters) + } + + fileprivate func postRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + return newRequest(method: .post, path: path, contentType: .formEncoded, parameters: parameters) + } + + fileprivate func putJSONRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + return newRequest(method: .put, path: path, contentType: .json, parameters: parameters) + } + + fileprivate func putRequest(path: String, parameters: [String : Any]) -> HTTPRequest { + return newRequest(method: .put, path: path, contentType: .formEncoded, parameters: parameters) + } + + fileprivate func newRequest(method: HTTPMethod, path: String, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) -> HTTPRequest { + let url = environment.baseURL.appendingPathComponent(path) + return newRequest(method: method, url: url, contentType: contentType, parameters: parameters) + } + + fileprivate func newRequest(method: HTTPMethod, url: URL, contentType: HTTPContentType = .none, parameters: [String : Any]? = nil) -> HTTPRequest { + let request = HTTPRequest(method: method, url: url, contentType: contentType, parameters: parameters) + + // Authorize requests to our service automatically. + if let token = self.token, url.hasBaseURL(environment.baseURL) { + authorizeRequest(request, token: token) + } + return request + } + + fileprivate func authorizeRequest(_ request: HTTPRequest, token: String) { + let encodedCredentials = "api:\(token)".base64 + let basicAuth = "Basic \(encodedCredentials)" + request.addHeader(name: "Authorization", value: basicAuth) + } + + func performRequest(_ request: HTTPRequest) -> Promise { + let urlRequest: URLRequest + do { + urlRequest = try RequestBuilder.build(request: request) + } + catch { + log.error("Invalid request \(request): \(error)") + return Promise(error: ServiceError.malformedRequest(request)) + } + return Promise { fulfill, reject in + let start = Date() + let task = self.urlSession.dataTask(with: urlRequest) { maybeData, maybeResponse, maybeError in + let response = HTTPResponse(response: maybeResponse, data: maybeData, error: maybeError) + _ = { + let end = Date() + let duration = end.timeIntervalSince1970 - start.timeIntervalSince1970 + self.logRequest(request, response: response, duration: duration) + }() + fulfill(response) + } + task.resume() + } + } + + private func scrubParameters(_ parameters: [String : Any], for url: URL) -> [String : Any] { + return parameters.reduce([:], { params, param in + var params = params + let (name, value) = param + let isBlacklisted = self.isBlacklisted(url: url, paramName: name) + params[name] = isBlacklisted ? "" : value + return params + }) + } + + private func isBlacklisted(url: URL, paramName: String) -> Bool { + return paramName.contains("password") + } + + private func logRequest(_ request: HTTPRequest, response: HTTPResponse, duration: TimeInterval) { + let method = request.method.string + let url = request.url + let type = response.headers["Content-Type"] ?? "no content" + let seconds = (1000 * duration).rounded() / 1000 + log.verbose("{\(seconds)s} \(method) \(url) -> \(response.status) (\(response.data?.count ?? 0) bytes, \(type))") + } +}