diff options
| author | Andrej Mihajlov <and@mullvad.net> | 2021-05-07 15:21:44 +0200 |
|---|---|---|
| committer | Andrej Mihajlov <and@mullvad.net> | 2021-05-12 10:04:08 +0200 |
| commit | aaf5e6915bdfd1e42ea2f4c70eac988beae34572 (patch) | |
| tree | 4b66cc44c93856922c5ea02708db4a04ef7fd843 | |
| parent | 3e2062338a6e934085e1fca54ab6609e37b79d9a (diff) | |
| download | mullvadvpn-aaf5e6915bdfd1e42ea2f4c70eac988beae34572.tar.xz mullvadvpn-aaf5e6915bdfd1e42ea2f4c70eac988beae34572.zip | |
Rework response handling and add support for HTTP caching via ETag header
| -rw-r--r-- | ios/MullvadVPN/MullvadRest.swift | 322 | ||||
| -rw-r--r-- | ios/MullvadVPN/RelayCache.swift | 53 |
2 files changed, 284 insertions, 91 deletions
diff --git a/ios/MullvadVPN/MullvadRest.swift b/ios/MullvadVPN/MullvadRest.swift index 1afe08e923..cf01e1bc37 100644 --- a/ios/MullvadVPN/MullvadRest.swift +++ b/ios/MullvadVPN/MullvadRest.swift @@ -23,8 +23,24 @@ enum HttpMethod: String { case delete = "DELETE" } +// HTTP status codes +enum HttpStatus { + static let ok = 200 + static let created = 201 + static let noContent = 204 + static let notModified = 304 +} + +/// HTTP headers +enum HttpHeader { + static let authorization = "Authorization" + static let contentType = "Content-Type" + static let etag = "ETag" + static let ifNoneMatch = "If-None-Match" +} + /// A struct that represents a server response in case of error (any HTTP status code except 2xx). -struct ServerErrorResponse: LocalizedError, Decodable, RestResponse, Equatable { +struct ServerErrorResponse: LocalizedError, Decodable, Equatable { /// A list of known server error codes enum Code: String, Equatable { case invalidAccount = "INVALID_ACCOUNT" @@ -113,29 +129,6 @@ protocol RestPayload { func inject(into request: inout URLRequest) throws } -/// Types conforming to this protocol can act as REST response types. -protocol RestResponse { - associatedtype Output - - static func decodeResponse(_ data: Data) throws -> Output -} - -/// Any `Decodable` can be REST response -extension Decodable where Self: RestResponse { - static func decodeResponse(_ data: Data) throws -> Self { - try MullvadRest.makeJSONDecoder().decode(Self.self, from: data) - } -} - -/// An empty REST response type that cannot be instantiated and is only used to produce an empty -/// output. -enum EmptyResponse {} -extension EmptyResponse: RestResponse { - static func decodeResponse(_ data: Data) throws -> () { - return () - } -} - /// Any `Encodable` type can be injected as JSON payload extension RestPayload where Self: Encodable { func inject(into request: inout URLRequest) throws { @@ -146,9 +139,9 @@ extension RestPayload where Self: Encodable { // MARK: - Operations final class RestOperation<Input, Response>: AsyncOperation, InputOperation, OutputOperation - where Input: RestPayload, Response: RestResponse + where Input: RestPayload { - typealias Output = Result<Response.Output, RestError> + typealias Output = Result<Response, RestError> private let endpoint: RestEndpoint<Input, Response> private let session: URLSession @@ -187,24 +180,140 @@ final class RestOperation<Input, Response>: AsyncOperation, InputOperation, Outp } } +// MARK: - Response handlers + +/// Types conforming to this protocol can be used as response handlers to decode the raw server response to the data +/// type expected by the caller. +protocol ResponseHandler { + associatedtype Response + + /// Decode the response. + /// The implementation is expected to throw `BadResponseError` in case of failure to handle the HTTP response, + /// or any other `Error` in case of failure to decode the data. + func decodeResponse(_ httpResponse: HTTPURLResponse, data: Data) -> Result<Response, ResponseHandlerError> +} + +enum ResponseHandlerError: Error { + /// A failure to handle the response due to unexpected status code + case badResponse(_ statusCode: Int) + + /// A failure to decode data + case decodeData(Error) +} + +/// A placeholder error used to indicate that the server returned unexpected response. +fileprivate struct BadResponseError: Error { + let statusCode: Int +} + +/// Type-erasing response handler. +struct AnyResponseHandler<Response>: ResponseHandler { + private let decodeResponseBlock: (HTTPURLResponse, Data) -> Result<Response, ResponseHandlerError> + + init<T: ResponseHandler>(_ wrappedHandler: T) where T.Response == Response { + self.decodeResponseBlock = { (response, data) -> Result<Response, ResponseHandlerError> in + return wrappedHandler.decodeResponse(response, data: data) + } + } + + func decodeResponse(_ httpResponse: HTTPURLResponse, data: Data) -> Result<Response, ResponseHandlerError> { + return self.decodeResponseBlock(httpResponse, data) + } +} + +/// A REST response handler that decides when response contains the successful result based on the given status code and +/// decodes the value in response. +struct DecodingResponseHandler<Response: Decodable>: ResponseHandler { + private let expectedStatus: Int + + init(expectedStatus: Int) { + self.expectedStatus = expectedStatus + } + + func decodeResponse(_ httpResponse: HTTPURLResponse, data: Data) -> Result<Response, ResponseHandlerError> { + if httpResponse.statusCode == expectedStatus { + return Result { try MullvadRest.makeJSONDecoder().decode(Response.self, from: data) } + .mapError { (error) -> ResponseHandlerError in + return .decodeData(error) + } + } else { + return .failure(.badResponse(httpResponse.statusCode)) + } + } +} + +/// A REST response handler that decides when response contains the successful result based on the given status code but +/// never decodes the value in response as it anticipates it to be empty. +struct EmptyResponseHandler: ResponseHandler { + private let expectedStatus: Int + + init(expectedStatus: Int) { + self.expectedStatus = expectedStatus + } + + func decodeResponse(_ httpResponse: HTTPURLResponse, data: Data) -> Result<(), ResponseHandlerError> { + if httpResponse.statusCode == expectedStatus { + return .success(()) + } else { + return .failure(.badResponse(httpResponse.statusCode)) + } + } +} + +/// A REST response handler that takes into account ETag and 200 and 304 response codes to produce the output result. +struct HttpCacheDecodingResponseHandler<WrappedType: Decodable>: ResponseHandler { + typealias Response = HttpResourceCacheResponse<WrappedType> + + private let etag: String? + + init(etag: String?) { + self.etag = etag + } + + func decodeResponse(_ httpResponse: HTTPURLResponse, data: Data) -> Result<Response, ResponseHandlerError> { + switch httpResponse.statusCode { + case HttpStatus.ok: + return Result { try MullvadRest.makeJSONDecoder().decode(WrappedType.self, from: data) } + .mapError { (error) -> ResponseHandlerError in + return .decodeData(error) + }.map { (relays) -> Response in + let etag = httpResponse.value(forCaseInsensitiveHTTPHeaderField: HttpHeader.etag) + + return .newContent(etag, relays) + } + + case HttpStatus.notModified where etag != nil: + return .success(.notModified) + + case let statusCode: + return .failure(.badResponse(statusCode)) + } + } +} + // MARK: - Endpoints /// A struct that describes the REST endpoint, including the expected input and output -struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse { +struct RestEndpoint<Input, Response> where Input: RestPayload { let endpointURL: URL let httpMethod: HttpMethod + let makeResponseHandler: (Input) -> AnyResponseHandler<Response> - init(endpointURL: URL, httpMethod: HttpMethod) { + init<Handler: ResponseHandler>(endpointURL: URL, httpMethod: HttpMethod, responseHandlerFactory: @escaping (Input) -> Handler) where Handler.Response == Response { self.endpointURL = endpointURL self.httpMethod = httpMethod + self.makeResponseHandler = { (input) -> AnyResponseHandler<Response> in + return AnyResponseHandler(responseHandlerFactory(input)) + } } /// Create `URLSessionDataTask` that automatically parses the HTTP response and returns the /// expected response type or error upon completion. - func dataTask(session: URLSession, payload: Input, completionHandler: @escaping (Result<Response.Output, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { + func dataTask(session: URLSession, payload: Input, completionHandler: @escaping (Result<Response, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { return makeURLRequest(payload: payload).map { (request) -> URLSessionDataTask in return session.dataTask(with: request) { (responseData, urlResponse, error) in - let result = Self.handleURLResponse(urlResponse, data: responseData, error: error) + let handler = self.makeResponseHandler(payload) + let result = Self.handleURLResponse(urlResponse, data: responseData, error: error, responseHandler: handler) completionHandler(result) } } @@ -236,13 +345,13 @@ struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestRes timeoutInterval: kNetworkTimeout ) request.httpShouldHandleCookies = false - request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("application/json", forHTTPHeaderField: HttpHeader.contentType) request.httpMethod = httpMethod.rawValue return request } /// A private HTTP response handler - private static func handleURLResponse(_ urlResponse: URLResponse?, data: Data?, error: Error?) -> Result<Response.Output, RestError> { + private static func handleURLResponse(_ urlResponse: URLResponse?, data: Data?, error: Error?, responseHandler: AnyResponseHandler<Response>) -> Result<Response, RestError> { if let error = error { let networkError = error as? URLError ?? URLError(.unknown) @@ -255,30 +364,26 @@ struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestRes let data = data ?? Data() - // Treat all 2xx responses as success despite the subtle meaning they may convey - if (200..<300).contains(httpResponse.statusCode) { - return Self.decodeSuccessResponse(data) - } else { - return Self.decodeErrorResponse(data) - .flatMap { (serverErrorResponse) -> Result<Response.Output, RestError> in - return .failure(.server(serverErrorResponse)) - } - } - } + return responseHandler.decodeResponse(httpResponse, data: data) + .flatMapError { (error) -> Result<Response, RestError> in + switch error { + case .badResponse: + // Try decoding the server error response in case when unexpected response is returned + return Self.decodeErrorResponse(httpResponse: httpResponse, data: data) + .flatMap { (serverErrorResponse) -> Result<Response, RestError> in + return .failure(.server(serverErrorResponse)) + } - /// A private helper that parses the JSON response in case of success (HTTP 2xx) - private static func decodeSuccessResponse(_ responseData: Data) -> Result<Response.Output, RestError> { - return Result { () -> Response.Output in - return try Response.decodeResponse(responseData) - }.mapError({ (error) -> RestError in - return .decodeSuccessResponse(error) - }) + case .decodeData(let decodingError): + return .failure(.decodeSuccessResponse(decodingError)) + } + } } /// A private helper that parses the JSON response in case of error (Any HTTP code except 2xx) - private static func decodeErrorResponse(_ responseData: Data) -> Result<ServerErrorResponse, RestError> { + private static func decodeErrorResponse(httpResponse: HTTPURLResponse, data: Data) -> Result<ServerErrorResponse, RestError> { return Result { () -> ServerErrorResponse in - return try ServerErrorResponse.decodeResponse(responseData) + return try MullvadRest.makeJSONDecoder().decode(ServerErrorResponse.self, from: data) }.mapError({ (error) -> RestError in return .decodeErrorResponse(error) }) @@ -286,7 +391,7 @@ struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestRes } /// A convenience class for `RestEndpoint` that transparently provides it with the `URLSession` -struct RestSessionEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse { +struct RestSessionEndpoint<Input, Response> where Input: RestPayload { let session: URLSession let endpoint: RestEndpoint<Input, Response> @@ -297,7 +402,7 @@ struct RestSessionEndpoint<Input, Response> where Input: RestPayload, Response: /// Create `URLSessionDataTask` that automatically parses the HTTP response and returns the /// expected response type or error upon completion. - func dataTask(payload: Input, completionHandler: @escaping (Result<Response.Output, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { + func dataTask(payload: Input, completionHandler: @escaping (Result<Response, RestError>) -> Void) -> Result<URLSessionDataTask, RestError> { return endpoint.dataTask(session: session, payload: payload, completionHandler: completionHandler) } @@ -321,7 +426,7 @@ struct MullvadRest { return RestSessionEndpoint(session: session, endpoint: Self.createAccount()) } - func getRelays() -> RestSessionEndpoint<EmptyPayload, ServerRelaysResponse> { + func getRelays() -> RestSessionEndpoint<ETagPayload<EmptyPayload>, HttpResourceCacheResponse<ServerRelaysResponse>> { return RestSessionEndpoint(session: session, endpoint: Self.getRelays()) } @@ -341,7 +446,7 @@ struct MullvadRest { return RestSessionEndpoint(session: session, endpoint: Self.replaceWireguardKey()) } - func deleteWireguardKey() -> RestSessionEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> { + func deleteWireguardKey() -> RestSessionEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, ()> { return RestSessionEndpoint(session: session, endpoint: Self.deleteWireguardKey()) } @@ -349,7 +454,7 @@ struct MullvadRest { return RestSessionEndpoint(session: session, endpoint: Self.createApplePayment()) } - func sendProblemReport() -> RestSessionEndpoint<ProblemReportRequest, EmptyResponse> { + func sendProblemReport() -> RestSessionEndpoint<ProblemReportRequest, ()> { return RestSessionEndpoint(session: session, endpoint: Self.sendProblemReport()) } } @@ -359,15 +464,21 @@ extension MullvadRest { static func createAccount() -> RestEndpoint<EmptyPayload, AccountResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("accounts"), - httpMethod: .post + httpMethod: .post, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.created) + } ) } /// GET /v1/relays - static func getRelays() -> RestEndpoint<EmptyPayload, ServerRelaysResponse> { + static func getRelays() -> RestEndpoint<ETagPayload<EmptyPayload>, HttpResourceCacheResponse<ServerRelaysResponse>> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("relays"), - httpMethod: .get + httpMethod: .get, + responseHandlerFactory: { (input) in + return HttpCacheDecodingResponseHandler(etag: input.etag) + } ) } @@ -375,14 +486,20 @@ extension MullvadRest { static func getAccountExpiry() -> RestEndpoint<TokenPayload<EmptyPayload>, AccountResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("me"), - httpMethod: .get + httpMethod: .get, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.ok) + } ) } /// GET /v1/wireguard-keys/{pubkey} static func getWireguardKey() -> RestEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, WireguardAddressesResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"), - httpMethod: .get + httpMethod: .get, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.ok) + } ) } @@ -390,7 +507,10 @@ extension MullvadRest { static func pushWireguardKey() -> RestEndpoint<TokenPayload<PushWireguardKeyRequest>, WireguardAddressesResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"), - httpMethod: .post + httpMethod: .post, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.created) + } ) } @@ -398,15 +518,21 @@ extension MullvadRest { static func replaceWireguardKey() -> RestEndpoint<TokenPayload<ReplaceWireguardKeyRequest>, WireguardAddressesResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("replace-wireguard-key"), - httpMethod: .post + httpMethod: .post, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.created) + } ) } /// DELETE /v1/wireguard-keys/{pubkey} - static func deleteWireguardKey() -> RestEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> { + static func deleteWireguardKey() -> RestEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, ()> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"), - httpMethod: .delete + httpMethod: .delete, + responseHandlerFactory: { (input) in + return EmptyResponseHandler(expectedStatus: HttpStatus.noContent) + } ) } @@ -414,14 +540,20 @@ extension MullvadRest { static func createApplePayment() -> RestEndpoint<TokenPayload<CreateApplePaymentRequest>, CreateApplePaymentResponse> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("create-apple-payment"), - httpMethod: .post + httpMethod: .post, + responseHandlerFactory: { (input) in + return DecodingResponseHandler(expectedStatus: HttpStatus.created) + } ) } - static func sendProblemReport() -> RestEndpoint<ProblemReportRequest, EmptyResponse> { + static func sendProblemReport() -> RestEndpoint<ProblemReportRequest, ()> { return RestEndpoint( endpointURL: kRestBaseURL.appendingPathComponent("problem-report"), - httpMethod: .post + httpMethod: .post, + responseHandlerFactory: { (input) in + return EmptyResponseHandler(expectedStatus: HttpStatus.noContent) + } ) } @@ -458,7 +590,7 @@ struct TokenPayload<Payload: RestPayload>: RestPayload { } func inject(into request: inout URLRequest) throws { - request.addValue("Token \(token)", forHTTPHeaderField: "Authorization") + request.addValue("Token \(token)", forHTTPHeaderField: HttpHeader.authorization) try payload.inject(into: &request) } } @@ -482,6 +614,30 @@ struct PublicKeyPayload<Payload: RestPayload>: RestPayload { } } +/// A payload that adds the ETag header to the request +struct ETagPayload<Payload: RestPayload>: RestPayload { + let etag: String? + let enforceWeakValidator: Bool + let payload: Payload + + init(etag: String?, enforceWeakValidator: Bool, payload: Payload) { + self.etag = etag + self.enforceWeakValidator = enforceWeakValidator + self.payload = payload + } + + func inject(into request: inout URLRequest) throws { + if var etag = etag { + // Enforce weak validator to account for some backend caching quirks. + if enforceWeakValidator && etag.starts(with: "\"") { + etag.insert(contentsOf: "W/", at: etag.startIndex) + } + request.setValue(etag, forHTTPHeaderField: HttpHeader.ifNoneMatch) + } + try payload.inject(into: &request) + } +} + /// An empty payload placeholder type. /// Use it in places where the payload is not expected struct EmptyPayload: RestPayload { @@ -492,7 +648,7 @@ struct EmptyPayload: RestPayload { // MARK: - Response types -struct AccountResponse: Decodable, RestResponse { +struct AccountResponse: Decodable { let token: String let expires: Date } @@ -524,7 +680,27 @@ struct ServerWireguardTunnels: Codable { let relays: [ServerRelay] } -struct ServerRelaysResponse: Codable, RestResponse { +private extension HTTPURLResponse { + func value(forCaseInsensitiveHTTPHeaderField headerField: String) -> String? { + if #available(iOS 13.0, *) { + return self.value(forHTTPHeaderField: headerField) + } else { + for case let key as String in self.allHeaderFields.keys { + if case .orderedSame = key.caseInsensitiveCompare(headerField) { + return self.allHeaderFields[key] as? String + } + } + return nil + } + } +} + +enum HttpResourceCacheResponse<T: Decodable> { + case notModified + case newContent(_ etag: String?, _ value: T) +} + +struct ServerRelaysResponse: Codable { let locations: [String: ServerLocation] let wireguard: ServerWireguardTunnels } @@ -533,7 +709,7 @@ struct PushWireguardKeyRequest: Encodable, RestPayload { let pubkey: Data } -struct WireguardAddressesResponse: Decodable, RestResponse { +struct WireguardAddressesResponse: Decodable { let id: String let pubkey: Data let ipv4Address: IPAddressRange @@ -549,7 +725,7 @@ struct CreateApplePaymentRequest: Encodable, RestPayload { let receiptString: Data } -struct CreateApplePaymentResponse: Decodable, RestResponse { +struct CreateApplePaymentResponse: Decodable { let timeAdded: Int let newExpiry: Date diff --git a/ios/MullvadVPN/RelayCache.swift b/ios/MullvadVPN/RelayCache.swift index 2d15f9a1fd..a41e64c3ac 100644 --- a/ios/MullvadVPN/RelayCache.swift +++ b/ios/MullvadVPN/RelayCache.swift @@ -191,39 +191,53 @@ class RelayCache { let nextUpdate = Self.nextUpdateDate(lastUpdatedAt: cachedRelays.updatedAt) if let nextUpdate = nextUpdate, nextUpdate <= Date() { - self.downloadRelays() + self.downloadRelays(previouslyCachedRelays: cachedRelays) } case .failure(let readError): self.logger.error(chainedError: readError, message: "Failed to read the relay cache to determine if it needs to be updated") if Self.shouldDownloadRelaysOnReadFailure(readError) { - self.downloadRelays() + self.downloadRelays(previouslyCachedRelays: nil) } } } - private func downloadRelays() { - let taskResult = makeDownloadTask { (result) in - let result = result.flatMap { (relays) -> Result<CachedRelays, RelayCacheError> in - let cachedRelays = CachedRelays(relays: relays, updatedAt: Date()) - - return Self.write(cacheFileURL: self.cacheFileURL, record: cachedRelays) - .map { cachedRelays } - } - + private func downloadRelays(previouslyCachedRelays: CachedRelays?) { + let taskResult = makeDownloadTask(etag: previouslyCachedRelays?.etag) { (result) in switch result { - case .success(let cachedRelays): - let numRelays = cachedRelays.relays.wireguard.relays.count + case .success(.newContent(let etag, let relays)): + let numRelays = relays.wireguard.relays.count self.logger.info("Downloaded \(numRelays) relays") - self.observerList.forEach { (observer) in - observer.relayCache(self, didUpdateCachedRelays: cachedRelays) + let cachedRelays = CachedRelays(etag: etag, relays: relays, updatedAt: Date()) + switch Self.write(cacheFileURL: self.cacheFileURL, record: cachedRelays) { + case .success: + self.observerList.forEach { (observer) in + observer.relayCache(self, didUpdateCachedRelays: cachedRelays) + } + + case .failure(let error): + self.logger.error(chainedError: error, message: "Failed to store downloaded relays") + } + + case .success(.notModified): + self.logger.info("Relays haven't changed since last check.") + + var cachedRelays = previouslyCachedRelays! + cachedRelays.updatedAt = Date() + + switch Self.write(cacheFileURL: self.cacheFileURL, record: cachedRelays) { + case .success: + break + + case .failure(let error): + self.logger.error(chainedError: error, message: "Failed to update cached relays timestamp") } case .failure(let error): - self.logger.error(chainedError: error, message: "Failed to update the relays") + self.logger.error(chainedError: error, message: "Failed to download relays") } } @@ -256,8 +270,8 @@ class RelayCache { self.timerSource = timerSource } - private func makeDownloadTask(completionHandler: @escaping (Result<ServerRelaysResponse, RelayCacheError>) -> Void) -> Result<URLSessionDataTask, RestError> { - return rest.getRelays().dataTask(payload: EmptyPayload()) { (result) in + private func makeDownloadTask(etag: String?, completionHandler: @escaping (Result<HttpResourceCacheResponse<ServerRelaysResponse>, RelayCacheError>) -> Void) -> Result<URLSessionDataTask, RestError> { + return rest.getRelays().dataTask(payload: ETagPayload(etag: etag, enforceWeakValidator: true, payload: EmptyPayload())) { (result) in self.dispatchQueue.async { completionHandler(result.mapError { RelayCacheError.rest($0) }) } @@ -369,6 +383,9 @@ class RelayCache { /// A struct that represents the relay cache on disk struct CachedRelays: Codable { + /// E-tag returned by server + var etag: String? + /// The relay list stored within the cache entry var relays: ServerRelaysResponse |
