Skip to content

Commit

Permalink
feat: added cookie handling (#37)
Browse files Browse the repository at this point in the history
  • Loading branch information
philprime authored Dec 6, 2023
1 parent 3809418 commit 4f1752a
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 1 deletion.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ struct MyRequest: JSONRequest {

// Status codes also have convenience utilities
@ResponseStatusCode var statusCode

// Cookies send by the remote
@RequestCookies var cookies
}

// The `keyEncodingStrategy` determines how to encode a type’s coding keys as JSON keys.
Expand All @@ -84,6 +87,9 @@ struct MyRequest: JSONRequest {

// Set request headers using the property naming
@RequestHeader var authorization: String?

// Set multiple instances of HTTPCookie
@RequestCookies var cookies
}

// Create a request
Expand Down Expand Up @@ -687,6 +693,24 @@ client.send(request) { result in
}
```

### Cookies

By default the cookies of requests and responses are handled by the `session` used by the `HTTPAPIClient`. If you want to explicitly set the request cookies, use `RequestCookies`, and to access the response cookies use `ResponseCookies`.

**Example:**

```swift
struct MyRequest: Request {
struct Response: Decodable {
// List of HTTPCookie parsed from the `Set-Cookie` headers of the response
@ResponseCookies var cookies
}

// List of HTTPCookie to be set in the request as `Cookie` headers
@RequestCookies var cookies
}
```

### Encoding & Decoding

The `RequestEncoder` is responsible to turn an encodable `Request` into an `URLRequest`. It requires an URL in the initializer, as Postie requests are relative requests.
Expand Down
19 changes: 19 additions & 0 deletions Sources/Postie/Cookies/RequestCookies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

@propertyWrapper
public struct RequestCookies {
public var wrappedValue: [HTTPCookie]

public init(wrappedValue: [HTTPCookie] = []) {
self.wrappedValue = wrappedValue
}
}

// MARK: Encodable

extension RequestCookies: Encodable {
public func encode(to encoder: Encoder) throws {
// This method needs to defined because `HTTPCookie` does not conform to `Encodable`, but should never be called anyways
preconditionFailure("\(Self.self).encode(to encoder:) should not be called")
}
}
25 changes: 25 additions & 0 deletions Sources/Postie/Cookies/ResponseCookies.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Foundation

@propertyWrapper
public struct ResponseCookies {

public var wrappedValue: [HTTPCookie]

public init(wrappedValue: [HTTPCookie] = []) {
self.wrappedValue = wrappedValue
}
}

extension ResponseCookies: Decodable {

public init(from decoder: Decoder) throws {
// Check if the decoder is response decoder, otherwise throw fatal error, because this property wrapper must use the correct decoder
guard let responseDecoding = decoder as? ResponseDecoding else {
preconditionFailure("\(Self.self) can only be used with \(ResponseDecoding.self)")
}
guard let url = responseDecoding.response.url, let allHeaderFields = responseDecoding.response.allHeaderFields as? [String: String] else {
throw APIError.invalidResponse
}
self.wrappedValue = HTTPCookie.cookies(withResponseHeaderFields: allHeaderFields, for: url)
}
}
5 changes: 4 additions & 1 deletion Sources/Postie/Encoder/RequestEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ public class RequestEncoder {
}
var request = URLRequest(url: url)
request.httpMethod = encoder.httpMethod.rawValue
request.allHTTPHeaderFields = encoder.headers
request.allHTTPHeaderFields = HTTPCookie
.requestHeaderFields(with: encoder.cookies)
// Merge the Cookie headers with the custom headers, where custom headers have precedence
.merging(encoder.headers, uniquingKeysWith: { $1 })
return request
}
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/Postie/Encoder/RequestEncoding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ internal class RequestEncoding: Encoder {
private(set) var queryItems: [URLQueryItem] = []
private(set) var headers: [String: String] = [:]
private(set) var pathParameters: [String: RequestPathParameterValue] = [:]
private(set) var cookies: [HTTPCookie] = []

init(parent: RequestEncoding? = nil, codingPath: [CodingKey] = []) {
self.parent = parent
Expand Down Expand Up @@ -79,6 +80,14 @@ internal class RequestEncoding: Encoder {
}
}

func setCookies(_ cookies: [HTTPCookie]) {
if let parent = parent {
parent.setCookies(cookies)
} else {
self.cookies += cookies
}
}

// MARK: - Accessors

func resolvedPath() throws -> String {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Postie/Encoder/RequestKeyedEncodingContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class RequestKeyedEncodingContainer<Key>: KeyedEncodingContainerProtocol where K
break
}
encoder.setCustomURL(url: customURL)
case let cookies as RequestCookies:
encoder.setCookies(cookies.wrappedValue)
default:
// ignore any other values
break
Expand Down
110 changes: 110 additions & 0 deletions Tests/PostieTests/Cookies/RequestCookiesCodingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// swiftlint:disable nesting
@testable import Postie
import XCTest

class RequestCookiesCodingTests: XCTestCase {
let baseURL = URL(string: "https://local.url")!
let cookies = [
HTTPCookie(properties: [
.domain: "local.url",
.path: "/some/path",
.name: "key",
.value: "value"
])!
]

func testEncoding_cookies_shouldBeSetInHeaders() {
struct Request: Postie.Request {
typealias Response = EmptyResponse

@RequestCookies var cookies
}

var request = Request()
request.cookies = cookies

let encoder = RequestEncoder(baseURL: baseURL)
let encoded: URLRequest
do {
encoded = try encoder.encode(request)
} catch {
XCTFail("Failed to encode: " + error.localizedDescription)
return
}
XCTAssertEqual(encoded.value(forHTTPHeaderField: "Cookie"), "key=value")
}

func testEncoding_emptyCookiesCustomHeader_shouldNotAffectExistingCookieHeaders() {
struct Request: Postie.Request {
typealias Response = EmptyResponse

@RequestCookies var cookies

@RequestHeader(name: "Cookie") var cookieHeader
}

var request = Request()
request.cookieHeader = "some header"

let encoder = RequestEncoder(baseURL: baseURL)
let encoded: URLRequest
do {
encoded = try encoder.encode(request)
} catch {
XCTFail("Failed to encode: " + error.localizedDescription)
return
}
XCTAssertEqual(encoded.value(forHTTPHeaderField: "Cookie"), "some header")
}

func testEncoding_cookiesAndCustomHeaders_shouldBeMergedIntoHeaders() {
struct Request: Postie.Request {
typealias Response = EmptyResponse

@RequestCookies var cookies

@RequestHeader(name: "Some-Header") var someHeader
}

var request = Request()
request.cookies = cookies
request.someHeader = "some header"

let encoder = RequestEncoder(baseURL: baseURL)
let encoded: URLRequest
do {
encoded = try encoder.encode(request)
} catch {
XCTFail("Failed to encode: " + error.localizedDescription)
return
}
XCTAssertEqual(encoded.value(forHTTPHeaderField: "Cookie"), "key=value")
XCTAssertEqual(encoded.value(forHTTPHeaderField: "Some-Header"), "some header")
}

func testEncoding_cookiesCustomHeader_shouldBeOverwrittenByCustomHeaders() {
struct Request: Postie.Request {
typealias Response = EmptyResponse

@RequestCookies var cookies

@RequestHeader(name: "Cookie") var cookieHeader
}

var request = Request()
request.cookies = cookies
request.cookieHeader = "some header"

let encoder = RequestEncoder(baseURL: baseURL)
let encoded: URLRequest
do {
encoded = try encoder.encode(request)
} catch {
XCTFail("Failed to encode: " + error.localizedDescription)
return
}
XCTAssertEqual(encoded.value(forHTTPHeaderField: "Cookie"), "some header")
}
}

// swiftlint:enable nesting
35 changes: 35 additions & 0 deletions Tests/PostieTests/Cookies/ResponseCookiesCodingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@testable import Postie
import XCTest

private struct Response: Decodable {
@ResponseCookies var cookies
}

class ResponseCookiesCodingTests: XCTestCase {
let response = HTTPURLResponse(url: URL(string: "https://example.local")!, statusCode: 200, httpVersion: nil, headerFields: [
// swiftlint:disable:next line_length
"Set-Cookie": "UserSession=xyz123; Domain=example.local; Path=/some/path; Expires=Wed, 06 Dec 2023 09:38:21 GMT; Max-Age=3600; Secure; HttpOnly; SameSite=Strict"
])!
let cookie = HTTPCookie(properties: [
.version: "0",
.name: "UserSession",
.value: "xyz123",
.domain: "example.local",
.path: "/some/path",

.expires: Date(timeIntervalSince1970: 1_701_859_806),
.maximumAge: "3600",

.secure: true,
.sameSitePolicy: "Strict"
])!

func testDecoding_defaultStrategy_shouldDecodeCaseInSensitiveResponseHeaders() {
let decoder = ResponseDecoder()
guard let decoded = try CheckNoThrow(decoder.decode(Response.self, from: (Data(), response))) else {
return
}
XCTAssertEqual(decoded.cookies.first?.name, "UserSession")
XCTAssertEqual(decoded.cookies.first?.value, "xyz123")
}
}

0 comments on commit 4f1752a

Please sign in to comment.