Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improved Request and Response APIs #11

Merged
merged 5 commits into from
Oct 30, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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