Skip to content

Commit 74edb43

Browse files
Pushkar N Kulkarniparkera
authored andcommitted
Implementation of HTTPCookie.cookies(withResponseHeaderFields:forURL:) (#423)
This if for an initial implementaion of HTTPCookie.cookies(withResponseHeaderFields:forURL:). In addition to the contributed unit tests, I have also tested this code against cookies returned by some of the twenty most popular websites. Kindly review and provide feedback.
1 parent 0ae2e28 commit 74edb43

File tree

2 files changed

+171
-3
lines changed

2 files changed

+171
-3
lines changed

Foundation/NSHTTPCookie.swift

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ public class HTTPCookie : NSObject {
6969
let _version: Int
7070
var _properties: [String : Any]
7171

72+
static let _attributes: [String] = [NSHTTPCookieName, NSHTTPCookieValue, NSHTTPCookieOriginURL, NSHTTPCookieVersion,
73+
NSHTTPCookieDomain, NSHTTPCookiePath, NSHTTPCookieSecure, NSHTTPCookieExpires,
74+
NSHTTPCookieComment, NSHTTPCookieCommentURL, NSHTTPCookieDiscard, NSHTTPCookieMaximumAge,
75+
NSHTTPCookiePort]
76+
7277
/// Initialize a NSHTTPCookie object with a dictionary of parameters
7378
///
7479
/// - Parameter properties: The dictionary of properties to be used to
@@ -327,16 +332,106 @@ public class HTTPCookie : NSObject {
327332
}
328333
return ["Cookie": cookieString]
329334
}
330-
335+
331336
/// Return an array of cookies parsed from the specified response header fields and URL.
332337
///
333338
/// This method will ignore irrelevant header fields so
334339
/// you can pass a dictionary containing data other than cookie data.
335340
/// - Parameter headerFields: The response header fields to check for cookies.
336341
/// - Parameter URL: The URL that the cookies came from - relevant to how the cookies are interpeted.
337342
/// - Returns: An array of NSHTTPCookie objects
338-
public class func cookies(withResponseHeaderFields headerFields: [String : String], forURL url: URL) -> [HTTPCookie] { NSUnimplemented() }
343+
public class func cookies(withResponseHeaderFields headerFields: [String : String], forURL url: URL) -> [HTTPCookie] {
344+
345+
//HTTP Cookie parsing based on RFC 6265: https://tools.ietf.org/html/rfc6265
346+
//Though RFC6265 suggests that multiple cookies cannot be folded into a single Set-Cookie field, this is
347+
//pretty common. It also suggests that commas and semicolons among other characters, cannot be a part of
348+
// names and values. This implementation takes care of multiple cookies in the same field, however it doesn't
349+
//support commas and semicolons in names and values(except for dates)
350+
351+
guard let cookies: String = headerFields["Set-Cookie"] else { return [] }
352+
353+
let nameValuePairs = cookies.components(separatedBy: ";") //split the name/value and attribute/value pairs
354+
.map({$0.trim()}) //trim whitespaces
355+
.map({removeCommaFromDate($0)}) //get rid of commas in dates
356+
.flatMap({$0.components(separatedBy: ",")}) //cookie boundaries are marked by commas
357+
.map({$0.trim()}) //trim again
358+
.filter({$0.caseInsensitiveCompare("HTTPOnly") != .orderedSame}) //we don't use HTTPOnly, do we?
359+
.flatMap({createNameValuePair(pair: $0)}) //create Name and Value properties
360+
361+
//mark cookie boundaries in the name-value array
362+
var cookieIndices = (0..<nameValuePairs.count).filter({nameValuePairs[$0].hasPrefix("Name")})
363+
cookieIndices.append(nameValuePairs.count)
364+
365+
//bake the cookies
366+
var httpCookies: [HTTPCookie] = []
367+
for i in 0..<cookieIndices.count-1 {
368+
if let aCookie = createHttpCookie(url: url, pairs: nameValuePairs, start: cookieIndices[i], end: cookieIndices[i+1]) {
369+
httpCookies.append(aCookie)
370+
}
371+
}
339372

373+
return httpCookies
374+
}
375+
376+
//Bake a cookie
377+
private class func createHttpCookie(url: URL, pairs: [String], start: Int, end: Int) -> HTTPCookie? {
378+
var properties: [String:Any] = [:]
379+
for index in start..<end {
380+
let name = pairs[index].components(separatedBy: "=")[0]
381+
var value = pairs[index].components(separatedBy: "\(name)=")[1] //a value can have an "="
382+
if canonicalize(name) == "Expires" {
383+
value = value.insertComma(at: 3) //re-insert the comma
384+
}
385+
properties[canonicalize(name)] = value
386+
}
387+
388+
//if domain wasn't provided use the URL
389+
if properties[NSHTTPCookieDomain] == nil {
390+
properties[NSHTTPCookieDomain] = url.absoluteString
391+
}
392+
393+
//the default Path is "/"
394+
if properties[NSHTTPCookiePath] == nil {
395+
properties[NSHTTPCookiePath] = "/"
396+
}
397+
398+
return HTTPCookie(properties: properties)
399+
}
400+
401+
//we pass this to a map()
402+
private class func removeCommaFromDate(_ value: String) -> String {
403+
if value.hasPrefix("Expires") || value.hasPrefix("expires") {
404+
return value.removeCommas()
405+
}
406+
return value
407+
}
408+
409+
//These cookie attributes are defined in RFC 6265 and 2965(which is obsolete)
410+
//HTTPCookie supports these
411+
private class func isCookieAttribute(_ string: String) -> Bool {
412+
return _attributes.first(where: {$0.caseInsensitiveCompare(string) == .orderedSame}) != nil
413+
}
414+
415+
//Cookie attribute names are case-insensitive as per RFC6265: https://tools.ietf.org/html/rfc6265
416+
//but HTTPCookie needs only the first letter of each attribute in uppercase
417+
private class func canonicalize(_ name: String) -> String {
418+
let idx = _attributes.index(where: {$0.caseInsensitiveCompare(name) == .orderedSame})!
419+
return _attributes[idx]
420+
}
421+
422+
//A name=value pair should be translated to two properties, Name=name and Value=value
423+
private class func createNameValuePair(pair: String) -> [String] {
424+
if pair.caseInsensitiveCompare(NSHTTPCookieSecure) == .orderedSame {
425+
return ["Secure=TRUE"]
426+
}
427+
let name = pair.components(separatedBy: "=")[0]
428+
let value = pair.components(separatedBy: "\(name)=")[1]
429+
if !isCookieAttribute(name) {
430+
return ["Name=\(name)", "Value=\(value)"]
431+
}
432+
return [pair]
433+
}
434+
340435
/// Returns a dictionary representation of the receiver.
341436
///
342437
/// This method returns a dictionary representation of the
@@ -459,3 +554,19 @@ public class HTTPCookie : NSObject {
459554
return _portList
460555
}
461556
}
557+
558+
//utils for cookie parsing
559+
internal extension String {
560+
func trim() -> String {
561+
return self.trimmingCharacters(in: NSCharacterSet.whitespacesAndNewlines())
562+
}
563+
564+
func removeCommas() -> String {
565+
return self.replacingOccurrences(of: ",", with: "")
566+
}
567+
568+
func insertComma(at index:Int) -> String {
569+
return String(self.characters.prefix(index)) + "," + String(self.characters.suffix(self.characters.count-index))
570+
}
571+
}
572+

TestFoundation/TestNSHTTPCookie.swift

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ class TestNSHTTPCookie: XCTestCase {
2020
static var allTests: [(String, (TestNSHTTPCookie) -> () throws -> Void)] {
2121
return [
2222
("test_BasicConstruction", test_BasicConstruction),
23-
("test_RequestHeaderFields", test_RequestHeaderFields)
23+
("test_RequestHeaderFields", test_RequestHeaderFields),
24+
("test_cookiesWithResponseHeader1cookie", test_cookiesWithResponseHeader1cookie),
25+
("test_cookiesWithResponseHeader0cookies", test_cookiesWithResponseHeader0cookies),
26+
("test_cookiesWithResponseHeader2cookies", test_cookiesWithResponseHeader2cookies),
27+
("test_cookiesWithResponseHeaderNoDomain", test_cookiesWithResponseHeaderNoDomain),
28+
("test_cookiesWithResponseHeaderNoPathNoDomain", test_cookiesWithResponseHeaderNoPathNoDomain)
2429
]
2530
}
2631

@@ -114,4 +119,56 @@ class TestNSHTTPCookie: XCTestCase {
114119
let basicCookieString = HTTPCookie.requestHeaderFields(with: basicCookies)["Cookie"]
115120
XCTAssertEqual(basicCookieString, "TestCookie1=testValue1; TestCookie2=testValue2")
116121
}
122+
123+
func test_cookiesWithResponseHeader1cookie() {
124+
let header = ["header1":"value1",
125+
"Set-Cookie": "fr=anjd&232; Max-Age=7776000; path=/; domain=.example.com; secure; httponly",
126+
"header2":"value2",
127+
"header3":"value3"]
128+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
129+
XCTAssertEqual(cookies.count, 1)
130+
XCTAssertEqual(cookies[0].name, "fr")
131+
XCTAssertEqual(cookies[0].value, "anjd&232")
132+
}
133+
134+
func test_cookiesWithResponseHeader0cookies() {
135+
let header = ["header1":"value1", "header2":"value2", "header3":"value3"]
136+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
137+
XCTAssertEqual(cookies.count, 0)
138+
}
139+
140+
func test_cookiesWithResponseHeader2cookies() {
141+
let header = ["header1":"value1",
142+
"Set-Cookie": "fr=a&2@#; Max-Age=1186000; path=/; domain=.example.com; secure, xd=plm!@#;path=/;domain=.example2.com",
143+
"header2":"value2",
144+
"header3":"value3"]
145+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
146+
XCTAssertEqual(cookies.count, 2)
147+
XCTAssertTrue(cookies[0].isSecure)
148+
XCTAssertFalse(cookies[1].isSecure)
149+
}
150+
151+
func test_cookiesWithResponseHeaderNoDomain() {
152+
let header = ["header1":"value1",
153+
"Set-Cookie": "fr=anjd&232; expires=Wed, 21 Sep 2016 05:33:00 GMT; Max-Age=7776000; path=/; secure; httponly",
154+
"header2":"value2",
155+
"header3":"value3"]
156+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
157+
XCTAssertEqual(cookies[0].domain, "http://example.com")
158+
let formatter = DateFormatter()
159+
formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss O"
160+
formatter.timeZone = TimeZone(abbreviation: "GMT")
161+
let expiresDate = formatter.date(from: "Wed, 21 Sep 2016 05:33:00 GMT")!
162+
XCTAssertTrue(expiresDate.compare(cookies[0].expiresDate!) == .orderedSame)
163+
}
164+
165+
func test_cookiesWithResponseHeaderNoPathNoDomain() {
166+
let header = ["header1":"value1",
167+
"Set-Cookie": "fr=tx; expires=Wed, 21-Sep-2016 05:33:00 GMT; Max-Age=7776000; secure; httponly",
168+
"header2":"value2",
169+
"header3":"value3"]
170+
let cookies = HTTPCookie.cookies(withResponseHeaderFields: header, forURL: URL(string: "http://example.com")!)
171+
XCTAssertEqual(cookies[0].domain, "http://example.com")
172+
XCTAssertEqual(cookies[0].path, "/")
173+
}
117174
}

0 commit comments

Comments
 (0)