add some more HTTP utilities

This commit is contained in:
Sami Samhuri 2017-08-24 12:09:39 -07:00
parent b685df4e90
commit 41769a99e8
No known key found for this signature in database
GPG key ID: F76F41F04D99808F
5 changed files with 539 additions and 6 deletions

80
FormEncoder.swift Normal file
View file

@ -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
}
}

145
HTTP.swift Normal file
View file

@ -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) ?? "<invalid data>"
log.error("Failed to parse JSON \(json): \(error)")
return [:]
}
}
}

View file

@ -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

52
RequestBuilder.swift Normal file
View file

@ -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
}
}

179
Service.swift Normal file
View file

@ -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<HTTPResponse> {
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<HTTPResponse> {
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 ? "<secret>" : 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))")
}
}