Skip to content

Commit

Permalink
Merge pull request #11 from SwiftOnEdge/improvements
Browse files Browse the repository at this point in the history
Improved Request and Response APIs
  • Loading branch information
Tyler Cloutier authored Oct 30, 2016
2 parents 7c8b595 + 8266ce5 commit 8124b93
Show file tree
Hide file tree
Showing 9 changed files with 357 additions and 35 deletions.
12 changes: 2 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,23 +53,15 @@ import Foundation

func handleRequest(request: Request) -> Response {
print(String(bytes: request.body, encoding: .utf8)!)
let responseBodyObject = ["message": "Message received!"]
let responseBody = Array(try! JSONSerialization.data(withJSONObject: responseBodyObject))

return Response(
version: Version(major: 1, minor: 1),
status: .ok,
rawHeaders: ["Content-Type", "application/json"],
body: responseBody
)
return try! Response(json: ["message": "Message received!"])
}

let server = HTTP.Server()
server.listen(host: "0.0.0.0", port: 3000).startWithNext { client in

let requestStream = client.read()
requestStream.map(transform: handleRequest).onNext{ response in
client.write(response)
client.write(response).start()
}

requestStream.onFailed { clientError in
Expand Down
5 changes: 3 additions & 2 deletions Sources/HTTP/ClientConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation
import TCP
import Reflex
import POSIX

public final class ClientConnection {

Expand Down Expand Up @@ -42,8 +43,8 @@ public final class ClientConnection {
}
}

public func write(_ response: Response) {
socket.write(buffer: response.serialized).start()
public func write(_ response: Response) -> ColdSignal<[UInt8], SystemError> {
return socket.write(buffer: response.serialized)
}

}
94 changes: 94 additions & 0 deletions Sources/HTTP/Message.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
//
// Message.swift
// Edge
//
// Created by Tyler Fleming Cloutier on 10/30/16.
//
//

import Foundation

public protocol HTTPMessage {
var version: Version { get set }
var rawHeaders: [String] { get set }
var headers: [String:String] { get }
var cookies: [String] { get }
var body: [UInt8] { get set }
}

public extension HTTPMessage {

/// Groups the `rawHeaders` into key-value pairs. If there is an odd number
/// of `rawHeaders`, the last value will be discarded.
var rawHeaderPairs: [(String, String)] {
return stride(from: 0, to: self.rawHeaders.count, by: 2).flatMap {
let chunk = rawHeaders[$0..<min($0 + 2, rawHeaders.count)]
if let first = chunk.first, let last = chunk.last {
return (first, last)
}
return nil
}
}

/// The same as `rawHeaderPairs` with the key lowercased.
var lowercasedRawHeaderPairs: [(String, String)] {
return rawHeaderPairs.map { ($0.0.lowercased(), $0.1) }
}

/// Duplicates are handled in a way very similar to the way they are handled
/// by Node.js. Which is to say that duplicates in the raw headers are handled as follows.
///
/// * Duplicates of age, authorization, content-length, content-type, etag, expires, from,
/// host, if-modified-since, if-unmodified-since, last-modified, location, max-forwards,
/// proxy-authorization, referer, retry-after, or user-agent are discarded.
/// * set-cookie is *excluded* from the formatted headers are handled by the request and
/// response. The cookies field on the Request and Response objects can be users to get
/// and set the cookies.
/// * For all other headers, the values are joined together with ', '.
///
/// The rawHeaders are processed from the 0th index forward.
var headers: [String:String] {
get {
var headers: [String:String] = [:]
let discardable = Set([
"age",
"authorization",
"content-length",
"content-type",
"etag",
"expires",
"from",
"host",
"if-modified-since",
"if-unmodified-since",
"last-modified",
"location",
"max-forwards",
"proxy-authorization",
"referer",
"retry-after",
"user-agent"
])
let cookies = Set([
"set-cookie",
"cookie"
])
for (key, value) in lowercasedRawHeaderPairs {
guard !cookies.contains(key) else {
continue
}
if let currentValue = headers[key] {
if discardable.contains(key) {
headers[key] = value
} else {
headers[key] = [currentValue, value].joined(separator: ", ")
}
} else {
headers[key] = value
}
}
return headers
}
}

}
31 changes: 19 additions & 12 deletions Sources/HTTP/Request.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import Foundation

public struct Request: Serializable {
public struct Request: Serializable, HTTPMessage {
public var method: Method
public var uri: URL
public var version: Version
Expand All @@ -18,24 +18,31 @@ public struct Request: Serializable {

public var serialized: [UInt8] {
var headerString = ""
headerString += "\(method) \(uri) HTTP/\(version.major).\(version.minor)"
headerString += "\n"
headerString += "\(method) \(uri.absoluteString) HTTP/\(version.major).\(version.minor)"
headerString += "\r\n"

let headerPairs: [(String, String)] = stride(from: 0, to: rawHeaders.count, by: 2).map {
let chunk = rawHeaders[$0..<min($0 + 2, rawHeaders.count)]
return (chunk.first!, chunk.last!)
}

for (name, value) in headerPairs {
for (name, value) in rawHeaderPairs {
headerString += "\(name): \(value)"
headerString += "\n"
headerString += "\r\n"
}

headerString += "\n"
headerString += "\r\n"
return headerString.utf8 + body
}

public var cookies: [String] {
return lowercasedRawHeaderPairs.filter { (key, value) in
key == "cookie"
}.map { $0.1 }
}

public init(method: Method, uri: URL, version: Version, rawHeaders: [String], body: [UInt8]) {
public init(
method: Method,
uri: URL,
version: Version = Version(major: 1, minor: 1),
rawHeaders: [String] = [],
body: [UInt8] = []
) {
self.method = method
self.uri = uri
self.version = version
Expand Down
41 changes: 30 additions & 11 deletions Sources/HTTP/Response.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
//
//

public struct Response: Serializable {
import Foundation

public struct Response: Serializable, HTTPMessage {

public var version: Version
public var status: Status
Expand All @@ -18,27 +20,44 @@ public struct Response: Serializable {
var headerString = ""
headerString += "HTTP/\(version.major).\(version.minor)"
headerString += " \(status.statusCode) \(status.reasonPhrase)"
headerString += "\n"

let headerPairs: [(String, String)] = stride(from: 0, to: rawHeaders.count, by: 2).map {
let chunk = rawHeaders[$0..<min($0 + 2, rawHeaders.count)]
return (chunk.first!, chunk.last!)
}
headerString += "\r\n"

for (name, value) in headerPairs {
for (name, value) in rawHeaderPairs {
headerString += "\(name): \(value)"
headerString += "\n"
headerString += "\r\n"
}

headerString += "\n"
headerString += "\r\n"
return headerString.utf8 + body
}

public init(version: Version, status: Status, rawHeaders: [String], body: [UInt8]) {
public var cookies: [String] {
return lowercasedRawHeaderPairs.filter { (key, value) in
key == "set-cookie"
}.map { $0.1 }
}

public init(
version: Version = Version(major: 1, minor: 1),
status: Status,
rawHeaders: [String] = [],
body: [UInt8] = []
) {
self.version = version
self.status = status
self.rawHeaders = rawHeaders
self.body = body
}

public init(
version: Version = Version(major: 1, minor: 1),
status: Status = .ok,
rawHeaders: [String] = [],
json: Any
) throws {
let rawHeaders = Array([rawHeaders, ["Content-Type", "application/json"]].joined())
let body = try JSONSerialization.data(withJSONObject: json)
self.init(version: version, status: status, rawHeaders: rawHeaders, body: Array(body))
}

}
78 changes: 78 additions & 0 deletions Tests/HTTPTests/HTTPMessageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
//
// HTTPMessageTests.swift
// Edge
//
// Created by Tyler Fleming Cloutier on 10/30/16.
//
//

import Foundation
import XCTest
@testable import HTTP

class HTTPMessageTests: XCTestCase {


struct TestMessageType: HTTPMessage {
var version = Version(major: 1, minor: 1)
var rawHeaders: [String] = []
var cookies: [String] {
return lowercasedRawHeaderPairs.filter { (key, value) in
key == "set-cookie"
}.map { $0.1 }
}
var body: [UInt8] = []
}

func testHeaders() {
var testMessage = TestMessageType()
testMessage.rawHeaders = [
"Date", "Sun, 30 Oct 2016 09:06:40 GMT",
"Expires", "-1",
"Cache-Control", "private, max-age=0",
"Content-Type", "application/json",
"Content-Type", "text/html; charset=ISO-8859-1",
"P3P","CP=\"See https://www.google.com/support/accounts/answer/151657?hl=en for more info.\"",
"Server", "gws",
"Server", "gws", // Duplicate servers for test purposes.
"X-XSS-Protection", "1; mode=block",
"X-Frame-Options", "SAMEORIGIN",
"Set-Cookie", "NID=89=c6V5PAWCEOXgvA6TQrNSR8Pnih2iX3Aa3rIQS005IG6WS8RHH" +
"_3YTmymtEk5yMxLkz19C_qr2zBNspy7zwubAVo38-kIdjbArSJcXCBbjCcn_hJ" +
"TEi9grq_ZgHxZTZ5V2YLnH3uxx6U4EA; expires=Mon, 01-May-2017 09:06:40 GMT;" +
" path=/; domain=.google.com; HttpOnly",
"Accept-Ranges", "none",
"Vary", "Accept-Encoding",
"Transfer-Encoding", "chunked"
]
let expectedHeaders = [
"date": "Sun, 30 Oct 2016 09:06:40 GMT",
"expires": "-1",
"cache-control": "private, max-age=0",
"content-type": "text/html; charset=ISO-8859-1",
"p3p": "CP=\"See https://www.google.com/support/accounts/answer/151657?hl=en for more info.\"",
"server": "gws, gws",
"x-xss-protection": "1; mode=block",
"x-frame-options": "SAMEORIGIN",
"accept-ranges": "none",
"vary": "Accept-Encoding",
"transfer-encoding": "chunked"
]
XCTAssert(testMessage.headers == expectedHeaders, "Actual headers, \(testMessage.headers), did not match expected.")
let expectedCookies = [
"NID=89=c6V5PAWCEOXgvA6TQrNSR8Pnih2iX3Aa3rIQS005IG6WS8RHH" +
"_3YTmymtEk5yMxLkz19C_qr2zBNspy7zwubAVo38-kIdjbArSJcXCBbjCcn_hJ" +
"TEi9grq_ZgHxZTZ5V2YLnH3uxx6U4EA; expires=Mon, 01-May-2017 09:06:40 GMT;" +
" path=/; domain=.google.com; HttpOnly"
]
XCTAssert(testMessage.cookies == expectedCookies, "Actual cookies, \(testMessage.cookies), did not match expected.")

}

}

extension HTTPMessageTests {
static var allTests = [
("testHeaders", testHeaders),
]
}
59 changes: 59 additions & 0 deletions Tests/HTTPTests/RequestSerializationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
//
// RequestSerializationTests.swift
// Edge
//
// Created by Tyler Fleming Cloutier on 10/30/16.
//
//

import Foundation
import XCTest
@testable import HTTP

class RequestSerializationTests: XCTestCase {

func testBasicSerialization() {
let expected = "GET / HTTP/1.1\r\n\r\n"
let request = Request(
method: .get,
uri: URL(string: "/")!,
version: Version(major: 1, minor: 1),
rawHeaders: [],
body: []
)
let actual = String(bytes: request.serialized, encoding: .utf8)!
XCTAssert(expected == actual, "Actual request, \(actual), did not match expected.")
}

func testHeaderSerialization() {
let expected = "GET / HTTP/1.1\r\nAccept: */*\r\nHost: www.google.com\r\nConnection: Keep-Alive\r\n\r\n"
let request = Request(
method: .get,
uri: URL(string: "/")!,
version: Version(major: 1, minor: 1),
rawHeaders: ["Accept", "*/*", "Host", "www.google.com", "Connection", "Keep-Alive"],
body: []
)
let actual = String(bytes: request.serialized, encoding: .utf8)!
XCTAssert(expected == actual, "Actual request, \(actual), did not match expected.")
}

func testDefaultParameters() {
let expected = "GET / HTTP/1.1\r\n\r\n"
let request = Request(
method: .get,
uri: URL(string: "/")!
)
let actual = String(bytes: request.serialized, encoding: .utf8)!
XCTAssert(expected == actual, "Actual request, \(actual), did not match expected.")
}

}

extension RequestSerializationTests {
static var allTests = [
("testBasicSerialization", testBasicSerialization),
("testHeaderSerialization", testHeaderSerialization),
("testDefaultParameters", testDefaultParameters),
]
}
Loading

0 comments on commit 8124b93

Please sign in to comment.