mirror of
https://github.com/samsonjs/Osiris.git
synced 2026-03-25 08:55:48 +00:00
add some more HTTP utilities
This commit is contained in:
parent
b685df4e90
commit
41769a99e8
5 changed files with 539 additions and 6 deletions
80
FormEncoder.swift
Normal file
80
FormEncoder.swift
Normal 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
145
HTTP.swift
Normal 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 [:]
|
||||
}
|
||||
}
|
||||
}
|
||||
89
Readme.md
89
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
|
||||
|
||||
|
|
|
|||
52
RequestBuilder.swift
Normal file
52
RequestBuilder.swift
Normal 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
179
Service.swift
Normal 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))")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue