Skip to content

Implementation of HTTPCookie.cookies(withResponseHeaderFields:forURL:) #423

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

Merged
merged 1 commit into from
Jun 28, 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
115 changes: 113 additions & 2 deletions Foundation/NSHTTPCookie.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ public class HTTPCookie : NSObject {
let _version: Int
var _properties: [String : Any]

static let _attributes: [String] = [NSHTTPCookieName, NSHTTPCookieValue, NSHTTPCookieOriginURL, NSHTTPCookieVersion,
NSHTTPCookieDomain, NSHTTPCookiePath, NSHTTPCookieSecure, NSHTTPCookieExpires,
NSHTTPCookieComment, NSHTTPCookieCommentURL, NSHTTPCookieDiscard, NSHTTPCookieMaximumAge,
NSHTTPCookiePort]

/// Initialize a NSHTTPCookie object with a dictionary of parameters
///
/// - Parameter properties: The dictionary of properties to be used to
Expand Down Expand Up @@ -327,16 +332,106 @@ public class HTTPCookie : NSObject {
}
return ["Cookie": cookieString]
}

/// Return an array of cookies parsed from the specified response header fields and URL.
///
/// This method will ignore irrelevant header fields so
/// you can pass a dictionary containing data other than cookie data.
/// - Parameter headerFields: The response header fields to check for cookies.
/// - Parameter URL: The URL that the cookies came from - relevant to how the cookies are interpeted.
/// - Returns: An array of NSHTTPCookie objects
public class func cookies(withResponseHeaderFields headerFields: [String : String], forURL url: URL) -> [HTTPCookie] { NSUnimplemented() }
public class func cookies(withResponseHeaderFields headerFields: [String : String], forURL url: URL) -> [HTTPCookie] {

//HTTP Cookie parsing based on RFC 6265: https://tools.ietf.org/html/rfc6265
//Though RFC6265 suggests that multiple cookies cannot be folded into a single Set-Cookie field, this is
//pretty common. It also suggests that commas and semicolons among other characters, cannot be a part of
// names and values. This implementation takes care of multiple cookies in the same field, however it doesn't
//support commas and semicolons in names and values(except for dates)

guard let cookies: String = headerFields["Set-Cookie"] else { return [] }

let nameValuePairs = cookies.components(separatedBy: ";") //split the name/value and attribute/value pairs
.map({$0.trim()}) //trim whitespaces
.map({removeCommaFromDate($0)}) //get rid of commas in dates
.flatMap({$0.components(separatedBy: ",")}) //cookie boundaries are marked by commas
.map({$0.trim()}) //trim again
.filter({$0.caseInsensitiveCompare("HTTPOnly") != .orderedSame}) //we don't use HTTPOnly, do we?
.flatMap({createNameValuePair(pair: $0)}) //create Name and Value properties

//mark cookie boundaries in the name-value array
var cookieIndices = (0..<nameValuePairs.count).filter({nameValuePairs[$0].hasPrefix("Name")})
cookieIndices.append(nameValuePairs.count)

//bake the cookies
var httpCookies: [HTTPCookie] = []
for i in 0..<cookieIndices.count-1 {
if let aCookie = createHttpCookie(url: url, pairs: nameValuePairs, start: cookieIndices[i], end: cookieIndices[i+1]) {
httpCookies.append(aCookie)
}
}

return httpCookies
}

//Bake a cookie
private class func createHttpCookie(url: URL, pairs: [String], start: Int, end: Int) -> HTTPCookie? {
var properties: [String:Any] = [:]
for index in start..<end {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to just use for aPair in pairs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see, we're parsing a subset of the array. I think it might be cleaner to just pass an ArraySlice into this.

let name = pairs[index].components(separatedBy: "=")[0]
var value = pairs[index].components(separatedBy: "\(name)=")[1] //a value can have an "="
if canonicalize(name) == "Expires" {
value = value.insertComma(at: 3) //re-insert the comma
}
properties[canonicalize(name)] = value
}

//if domain wasn't provided use the URL
if properties[NSHTTPCookieDomain] == nil {
properties[NSHTTPCookieDomain] = url.absoluteString
}

//the default Path is "/"
if properties[NSHTTPCookiePath] == nil {
properties[NSHTTPCookiePath] = "/"
}

return HTTPCookie(properties: properties)
}

//we pass this to a map()
private class func removeCommaFromDate(_ value: String) -> String {
if value.hasPrefix("Expires") || value.hasPrefix("expires") {
return value.removeCommas()
}
return value
}

//These cookie attributes are defined in RFC 6265 and 2965(which is obsolete)
//HTTPCookie supports these
private class func isCookieAttribute(_ string: String) -> Bool {
return _attributes.first(where: {$0.caseInsensitiveCompare(string) == .orderedSame}) != nil
}

//Cookie attribute names are case-insensitive as per RFC6265: https://tools.ietf.org/html/rfc6265
//but HTTPCookie needs only the first letter of each attribute in uppercase
private class func canonicalize(_ name: String) -> String {
let idx = _attributes.index(where: {$0.caseInsensitiveCompare(name) == .orderedSame})!
return _attributes[idx]
}

//A name=value pair should be translated to two properties, Name=name and Value=value
private class func createNameValuePair(pair: String) -> [String] {
if pair.caseInsensitiveCompare(NSHTTPCookieSecure) == .orderedSame {
return ["Secure=TRUE"]
}
let name = pair.components(separatedBy: "=")[0]
let value = pair.components(separatedBy: "\(name)=")[1]
if !isCookieAttribute(name) {
return ["Name=\(name)", "Value=\(value)"]
}
return [pair]
}

/// Returns a dictionary representation of the receiver.
///
/// This method returns a dictionary representation of the
Expand Down Expand Up @@ -459,3 +554,19 @@ public class HTTPCookie : NSObject {
return _portList
}
}

//utils for cookie parsing
internal extension String {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A style note, but I'd rather just put these functions inline at the call site than extending String in the entire project.

func trim() -> String {
return self.trimmingCharacters(in: NSCharacterSet.whitespacesAndNewlines())
}

func removeCommas() -> String {
return self.replacingOccurrences(of: ",", with: "")
}

func insertComma(at index:Int) -> String {
return String(self.characters.prefix(index)) + "," + String(self.characters.suffix(self.characters.count-index))
}
}

59 changes: 58 additions & 1 deletion TestFoundation/TestNSHTTPCookie.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ class TestNSHTTPCookie: XCTestCase {
static var allTests: [(String, (TestNSHTTPCookie) -> () throws -> Void)] {
return [
("test_BasicConstruction", test_BasicConstruction),
("test_RequestHeaderFields", test_RequestHeaderFields)
("test_RequestHeaderFields", test_RequestHeaderFields),
("test_cookiesWithResponseHeader1cookie", test_cookiesWithResponseHeader1cookie),
("test_cookiesWithResponseHeader0cookies", test_cookiesWithResponseHeader0cookies),
("test_cookiesWithResponseHeader2cookies", test_cookiesWithResponseHeader2cookies),
("test_cookiesWithResponseHeaderNoDomain", test_cookiesWithResponseHeaderNoDomain),
("test_cookiesWithResponseHeaderNoPathNoDomain", test_cookiesWithResponseHeaderNoPathNoDomain)
]
}

Expand Down Expand Up @@ -114,4 +119,56 @@ class TestNSHTTPCookie: XCTestCase {
let basicCookieString = HTTPCookie.requestHeaderFields(with: basicCookies)["Cookie"]
XCTAssertEqual(basicCookieString, "TestCookie1=testValue1; TestCookie2=testValue2")
}

func test_cookiesWithResponseHeader1cookie() {
let header = ["header1":"value1",
"Set-Cookie": "fr=anjd&232; Max-Age=7776000; path=/; domain=.example.com; secure; httponly",
"header2":"value2",
"header3":"value3"]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
XCTAssertEqual(cookies.count, 1)
XCTAssertEqual(cookies[0].name, "fr")
XCTAssertEqual(cookies[0].value, "anjd&232")
}

func test_cookiesWithResponseHeader0cookies() {
let header = ["header1":"value1", "header2":"value2", "header3":"value3"]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
XCTAssertEqual(cookies.count, 0)
}

func test_cookiesWithResponseHeader2cookies() {
let header = ["header1":"value1",
"Set-Cookie": "fr=a&2@#; Max-Age=1186000; path=/; domain=.example.com; secure, xd=plm!@#;path=/;domain=.example2.com",
"header2":"value2",
"header3":"value3"]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
XCTAssertEqual(cookies.count, 2)
XCTAssertTrue(cookies[0].isSecure)
XCTAssertFalse(cookies[1].isSecure)
}

func test_cookiesWithResponseHeaderNoDomain() {
let header = ["header1":"value1",
"Set-Cookie": "fr=anjd&232; expires=Wed, 21 Sep 2016 05:33:00 GMT; Max-Age=7776000; path=/; secure; httponly",
"header2":"value2",
"header3":"value3"]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
XCTAssertEqual(cookies[0].domain, "http://example.com")
let formatter = DateFormatter()
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss O"
formatter.timeZone = TimeZone(abbreviation: "GMT")
let expiresDate = formatter.date(from: "Wed, 21 Sep 2016 05:33:00 GMT")!
XCTAssertTrue(expiresDate.compare(cookies[0].expiresDate!) == .orderedSame)
}

func test_cookiesWithResponseHeaderNoPathNoDomain() {
let header = ["header1":"value1",
"Set-Cookie": "fr=tx; expires=Wed, 21-Sep-2016 05:33:00 GMT; Max-Age=7776000; secure; httponly",
"header2":"value2",
"header3":"value3"]
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
XCTAssertEqual(cookies[0].domain, "http://example.com")
XCTAssertEqual(cookies[0].path, "/")
}
}