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
|
# 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
|
# 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
|
# Multipart Form Encoding
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
Create an encoder and then add parts to it as needed:
|
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)`.
|
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
|
# Credits
|
||||||
|
|
||||||
Created by Sami Samhuri for [1SE][].
|
Mostly created by Sami Samhuri for [1SE][]. `FormEncoder.swift` was lifted from [Alamofire][].
|
||||||
|
|
||||||
[1SE]: http://1se.co
|
[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