summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAndrej Mihajlov <and@mullvad.net>2020-07-10 15:58:30 +0200
committerAndrej Mihajlov <and@mullvad.net>2020-07-22 18:35:43 +0300
commitc44e4c297769b3587929c6505e6ef5f7e1d58f95 (patch)
tree61e01becf6220fb52f989f6d7b6da4a3be56499b
parentf10dc9875b3d8e5d35448af2a9c58b9db07b8829 (diff)
downloadmullvadvpn-c44e4c297769b3587929c6505e6ef5f7e1d58f95.tar.xz
mullvadvpn-c44e4c297769b3587929c6505e6ef5f7e1d58f95.zip
Add MullvadRest implementation
-rw-r--r--ios/MullvadVPN.xcodeproj/project.pbxproj8
-rw-r--r--ios/MullvadVPN/MullvadRest.swift509
2 files changed, 516 insertions, 1 deletions
diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj
index 8fa3f3734e..9d2adfa428 100644
--- a/ios/MullvadVPN.xcodeproj/project.pbxproj
+++ b/ios/MullvadVPN.xcodeproj/project.pbxproj
@@ -140,6 +140,8 @@
58C6B36122C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */; };
58C6B36522C10596003C19AD /* AnyIPEndpoint+Wireguard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */; };
58C6B36722C106FC003C19AD /* WireguardCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6B36622C106FC003C19AD /* WireguardCommand.swift */; };
+ 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */; };
+ 58CB0EE124B86751001EF0D8 /* MullvadRest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */; };
58CC40EF24A601900019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
58CC40F024A602780019D96E /* ObserverList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CC40EE24A601900019D96E /* ObserverList.swift */; };
58CCA010224249A1004F3011 /* ConnectViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58CCA00F224249A1004F3011 /* ConnectViewController.swift */; };
@@ -316,6 +318,7 @@
58C6B36022C0EC82003C19AD /* AnyIPEndpoint+DNS64.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+DNS64.swift"; sourceTree = "<group>"; };
58C6B36422C10596003C19AD /* AnyIPEndpoint+Wireguard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AnyIPEndpoint+Wireguard.swift"; sourceTree = "<group>"; };
58C6B36622C106FC003C19AD /* WireguardCommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WireguardCommand.swift; sourceTree = "<group>"; };
+ 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MullvadRest.swift; sourceTree = "<group>"; };
58CC40EE24A601900019D96E /* ObserverList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObserverList.swift; sourceTree = "<group>"; };
58CCA00F224249A1004F3011 /* ConnectViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectViewController.swift; sourceTree = "<group>"; };
58CCA01122424D11004F3011 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = "<group>"; };
@@ -505,6 +508,7 @@
5840250022B1124600E4CFEC /* IpAddress+Codable.swift */,
58C6B34E22BB7AC0003C19AD /* IPAddressRange.swift */,
58561C98239A5D1500BD6B5E /* IPEndpoint.swift */,
+ 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */,
58FAEDF6245088E100CB0F5B /* Keychain.swift */,
58FAEDEB245059F000CB0F5B /* KeychainAttributes.swift */,
58FAEE0024533A9C00CB0F5B /* KeychainClass.swift */,
@@ -518,6 +522,7 @@
58CE5E65224146200008646E /* LoginViewController.swift */,
58CE5E67224146200008646E /* Main.storyboard */,
5840250322B11AB700E4CFEC /* MullvadEndpoint.swift */,
+ 58CB0EDF24B86751001EF0D8 /* MullvadRest.swift */,
58ADDB3D227B1CD900FAFEA7 /* MullvadRpc.swift */,
58FBDAAA22A52DC500EB69A3 /* MullvadVPN-Bridging-Header.h */,
5866F39B2243B82D00168AE5 /* MullvadVPN.entitlements */,
@@ -526,7 +531,6 @@
58CC40EE24A601900019D96E /* ObserverList.swift */,
580EE1FF24B3218800F9D8A1 /* Operations */,
5845F841236CBACD00B2D93C /* PacketTunnelIpc.swift */,
- 58ADDB3B227B1BD200FAFEA7 /* JsonRpc.swift */,
58BFA5C522A7C97F00A6173D /* RelayCache.swift */,
58781CC822AE7CA8009B9D8E /* RelayConstraints.swift */,
5888AD88227B18C40051EB06 /* RelayList.swift */,
@@ -904,6 +908,7 @@
5877153023981F7B001F8237 /* WireguardKeysViewController.swift in Sources */,
58FAEDEF245069C700CB0F5B /* KeychainAttributes.swift in Sources */,
58C6B35422BB87C4003C19AD /* WireguardPrivateKey.swift in Sources */,
+ 58CB0EE024B86751001EF0D8 /* MullvadRest.swift in Sources */,
580EE20924B3224200F9D8A1 /* RetryOperation.swift in Sources */,
582AE3102440A6CA00E6733A /* AccountTokenInput.swift in Sources */,
58FAEDF7245088E100CB0F5B /* Keychain.swift in Sources */,
@@ -986,6 +991,7 @@
files = (
5860F1C423A8D25F00CEA666 /* WireguardConfiguration.swift in Sources */,
58F3C09D249B99DD003E76BE /* Curve25519.swift in Sources */,
+ 58CB0EE124B86751001EF0D8 /* MullvadRest.swift in Sources */,
580EE21F24B3237F00F9D8A1 /* OutputOperation.swift in Sources */,
580EE20224B321DB00F9D8A1 /* OperationProtocol.swift in Sources */,
58FAEE0224533ABB00CB0F5B /* KeychainMatchLimit.swift in Sources */,
diff --git a/ios/MullvadVPN/MullvadRest.swift b/ios/MullvadVPN/MullvadRest.swift
new file mode 100644
index 0000000000..7d587f52d6
--- /dev/null
+++ b/ios/MullvadVPN/MullvadRest.swift
@@ -0,0 +1,509 @@
+//
+// MullvadRest.swift
+// MullvadVPN
+//
+// Created by pronebird on 10/07/2020.
+// Copyright © 2020 Mullvad VPN AB. All rights reserved.
+//
+
+import Foundation
+import Network
+
+/// REST API v1 base URL
+private let kRestBaseURL = URL(string: "https://api.mullvad.net/app/v1")!
+
+/// Network request timeout in seconds
+private let kNetworkTimeout: TimeInterval = 10
+
+/// HTTP method
+enum HttpMethod: String {
+ case get = "GET"
+ case post = "POST"
+ case delete = "DELETE"
+}
+
+/// A known list of Rest API error codes
+enum RestErrorCode: String {
+ case invalidAccount = "INVALID_ACCOUNT"
+ case keyLimitReached = "KEY_LIMIT_REACHED"
+}
+
+/// A struct that represents a server response in case of error (any HTTP status code except 2xx).
+struct ServerErrorResponse: LocalizedError, Decodable, RestResponse {
+ let code: String
+ let error: String?
+
+ var errorDescription: String? {
+ switch code {
+ case RestErrorCode.keyLimitReached.rawValue:
+ return NSLocalizedString("Too many public WireGuard keys", comment: "")
+ case RestErrorCode.invalidAccount.rawValue:
+ return NSLocalizedString("Invalid account", comment: "")
+ default:
+ return nil
+ }
+ }
+
+ var recoverySuggestion: String? {
+ switch code {
+ case RestErrorCode.keyLimitReached.rawValue:
+ return NSLocalizedString("Remove unused WireGuard keys", comment: "")
+ default:
+ return nil
+ }
+ }
+}
+
+/// An error type returned by `MullvadRest`
+enum RestError: ChainedError {
+ /// A failure to encode the payload
+ case encodePayload(Error)
+
+ /// A failure during networking
+ case network(URLError)
+
+ /// A failure reported by server
+ case server(ServerErrorResponse)
+
+ /// A failure to decode the error response from server
+ case decodeErrorResponse(Error)
+
+ /// A failure to decode the success response from server
+ case decodeSuccessResponse(Error)
+
+ var errorDescription: String? {
+ switch self {
+ case .encodePayload:
+ return "Failure to encode the payload"
+ case .network:
+ return "Network error"
+ case .server:
+ return "Server error"
+ case .decodeErrorResponse:
+ return "Failure to decode error response from server"
+ case .decodeSuccessResponse:
+ return "Failure to decode success response from server"
+ }
+ }
+}
+
+/// Types conforming to this protocol can participate in forming the `URLRequest` created by
+/// `RestEndpoint`.
+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 {
+ request.httpBody = try MullvadRest.makeJSONEncoder().encode(self)
+ }
+}
+
+// MARK: - Operations
+
+final class RestOperation<Input, Response>: AsyncOperation, InputOperation, OutputOperation
+ where Input: RestPayload, Response: RestResponse
+{
+ typealias Output = Result<Response.Output, RestError>
+
+ private let endpoint: RestEndpoint<Input, Response>
+ private let session: URLSession
+ private var task: URLSessionTask?
+
+ init(endpoint: RestEndpoint<Input, Response>, session: URLSession, input: Input? = nil) {
+ self.endpoint = endpoint
+ self.session = session
+
+ super.init()
+ self.input = input
+ }
+
+ override func main() {
+ guard let payload = self.input else {
+ finish()
+ return
+ }
+
+ let result = endpoint.dataTask(session: session, payload: payload) { [weak self] (result) in
+ self?.finish(with: result)
+ }
+
+ switch result {
+ case .success(let task):
+ self.task = task
+ task.resume()
+ case .failure(let error):
+ finish(with: .failure(error))
+ }
+ }
+
+ override func operationDidCancel() {
+ task?.cancel()
+ task = nil
+ }
+}
+
+// MARK: - Endpoints
+
+/// A struct that describes the REST endpoint, including the expected input and output
+struct RestEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse {
+ let endpointURL: URL
+ let httpMethod: HttpMethod
+
+ init(endpointURL: URL, httpMethod: HttpMethod) {
+ self.endpointURL = endpointURL
+ self.httpMethod = httpMethod
+ }
+
+ /// 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> {
+ 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)
+ completionHandler(result)
+ }
+ }
+ }
+
+ /// Create `RestOperation` that automatically parses the response and sets the expected output
+ /// type or error upon completion.
+ func operation(session: URLSession, payload: Input?) -> RestOperation<Input, Response> {
+ return RestOperation(endpoint: self, session: session, input: payload)
+ }
+
+ /// Create `URLRequest` that can be used to send an HTTP request
+ private func makeURLRequest(payload: Input) -> Result<URLRequest, RestError> {
+ var request = makeEndpointURLRequest()
+ do {
+ try payload.inject(into: &request)
+
+ return .success(request)
+ } catch {
+ return .failure(.encodePayload(error))
+ }
+ }
+
+ /// Create a boilerplate `URLRequest` before injecting the payload
+ private func makeEndpointURLRequest() -> URLRequest {
+ var request = URLRequest(
+ url: endpointURL,
+ cachePolicy: .useProtocolCachePolicy,
+ timeoutInterval: kNetworkTimeout
+ )
+ request.httpShouldHandleCookies = false
+ request.addValue("application/json", forHTTPHeaderField: "Content-Type")
+ 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> {
+ if let error = error {
+ let networkError = error as? URLError ?? URLError(.unknown)
+
+ return .failure(.network(networkError))
+ }
+
+ guard let httpResponse = urlResponse as? HTTPURLResponse else {
+ return .failure(.network(URLError(.unknown)))
+ }
+
+ 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))
+ }
+ }
+ }
+
+ /// A private helper that parses the JSON response in case of success (HTTP 200)
+ 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)
+ })
+ }
+
+ /// A private helper that parses the JSON response in case of error (Any HTTP code except 200)
+ private static func decodeErrorResponse(_ responseData: Data) -> Result<ServerErrorResponse, RestError> {
+ return Result { () -> ServerErrorResponse in
+ return try ServerErrorResponse.decodeResponse(responseData)
+ }.mapError({ (error) -> RestError in
+ return .decodeErrorResponse(error)
+ })
+ }
+}
+
+/// A convenience class for `RestEndpoint` that transparently provides it with the `URLSession`
+struct RestSessionEndpoint<Input, Response> where Input: RestPayload, Response: RestResponse {
+ let session: URLSession
+ let endpoint: RestEndpoint<Input, Response>
+
+ init(session: URLSession, endpoint: RestEndpoint<Input, Response>) {
+ self.session = session
+ self.endpoint = endpoint
+ }
+
+ /// 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> {
+ return endpoint.dataTask(session: session, payload: payload, completionHandler: completionHandler)
+ }
+
+ /// Create `RestOperation` that automatically parses the response and sets the expected output
+ /// type or error upon completion.
+ func operation(payload: Input?) -> RestOperation<Input, Response> {
+ return endpoint.operation(session: session, payload: payload)
+ }
+}
+
+// MARK: - REST interface
+
+struct MullvadRest {
+ let session: URLSession
+
+ init(session: URLSession = URLSession(configuration: .ephemeral)) {
+ self.session = session
+ }
+
+ func createAccount() -> RestSessionEndpoint<EmptyPayload, AccountResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.createAccount())
+ }
+
+ func getRelays() -> RestSessionEndpoint<EmptyPayload, ServerRelaysResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.getRelays())
+ }
+
+ func getAccountExpiry() -> RestSessionEndpoint<TokenPayload<EmptyPayload>, AccountResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.getAccountExpiry())
+ }
+
+ func pushWireguardKey() -> RestSessionEndpoint<TokenPayload<PushWireguardKeyRequest>, WireguardAddressesResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.pushWireguardKey())
+ }
+
+ func replaceWireguardKey() -> RestSessionEndpoint<TokenPayload<ReplaceWireguardKeyRequest>, WireguardAddressesResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.replaceWireguardKey())
+ }
+
+ func deleteWireguardKey() -> RestSessionEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.deleteWireguardKey())
+ }
+
+ func createApplePayment() -> RestSessionEndpoint<TokenPayload<CreateApplePaymentRequest>, CreateApplePaymentResponse> {
+ return RestSessionEndpoint(session: session, endpoint: Self.createApplePayment())
+ }
+}
+
+extension MullvadRest {
+ /// POST /v1/accounts
+ static func createAccount() -> RestEndpoint<EmptyPayload, AccountResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("accounts"),
+ httpMethod: .post
+ )
+ }
+
+ /// GET /v1/relays
+ static func getRelays() -> RestEndpoint<EmptyPayload, ServerRelaysResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("relays"),
+ httpMethod: .get
+ )
+ }
+
+ /// GET /v1/me
+ static func getAccountExpiry() -> RestEndpoint<TokenPayload<EmptyPayload>, AccountResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("me"),
+ httpMethod: .get
+ )
+ }
+
+ /// POST /v1/wireguard-keys
+ static func pushWireguardKey() -> RestEndpoint<TokenPayload<PushWireguardKeyRequest>, WireguardAddressesResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"),
+ httpMethod: .post
+ )
+ }
+
+ /// POST /v1/replace-wireguard-key
+ static func replaceWireguardKey() -> RestEndpoint<TokenPayload<ReplaceWireguardKeyRequest>, WireguardAddressesResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("replace-wireguard-key"),
+ httpMethod: .post
+ )
+ }
+
+ /// DELETE /v1/wireguard-keys/{pubkey}
+ static func deleteWireguardKey() -> RestEndpoint<PublicKeyPayload<TokenPayload<EmptyPayload>>, EmptyResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("wireguard-keys"),
+ httpMethod: .delete
+ )
+ }
+
+ /// POST /v1/create-apple-payment
+ static func createApplePayment() -> RestEndpoint<TokenPayload<CreateApplePaymentRequest>, CreateApplePaymentResponse> {
+ return RestEndpoint(
+ endpointURL: kRestBaseURL.appendingPathComponent("create-apple-payment"),
+ httpMethod: .post
+ )
+ }
+
+ /// Returns a JSON encoder used by REST API
+ static func makeJSONEncoder() -> JSONEncoder {
+ let encoder = JSONEncoder()
+ encoder.keyEncodingStrategy = .convertToSnakeCase
+ encoder.dateEncodingStrategy = .iso8601
+ encoder.dataEncodingStrategy = .base64
+ return encoder
+ }
+
+ /// Returns a JSON decoder used by REST API
+ static func makeJSONDecoder() -> JSONDecoder {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ decoder.dateDecodingStrategy = .iso8601
+ decoder.dataDecodingStrategy = .base64
+ return decoder
+ }
+}
+
+
+// MARK: - Payload types
+
+/// A payload that adds the authentication token into HTTP Authorization header
+struct TokenPayload<Payload: RestPayload>: RestPayload {
+ let token: String
+ let payload: Payload
+
+ init(token: String, payload: Payload) {
+ self.token = token
+ self.payload = payload
+ }
+
+ func inject(into request: inout URLRequest) throws {
+ request.addValue("Token \(token)", forHTTPHeaderField: "Authorization")
+ try payload.inject(into: &request)
+ }
+}
+
+/// A payload that adds the public key into the URL path
+struct PublicKeyPayload<Payload: RestPayload>: RestPayload {
+ let pubKey: Data
+ let payload: Payload
+
+ init(pubKey: Data, payload: Payload) {
+ self.pubKey = pubKey
+ self.payload = payload
+ }
+
+ func inject(into request: inout URLRequest) throws {
+ request.url = request.url?.appendingPathComponent(pubKey.base64EncodedString())
+ try payload.inject(into: &request)
+ }
+}
+
+/// An empty payload placeholder type.
+/// Use it in places where the payload is not expected
+struct EmptyPayload: RestPayload {
+ init() {}
+ func inject(into request: inout URLRequest) throws {}
+}
+
+
+// MARK: - Response types
+
+struct AccountResponse: Decodable, RestResponse {
+ let token: String
+ let expires: Date
+}
+
+struct ServerLocation: Decodable {
+ let country: String
+ let city: String
+ let latitude: Double
+ let longitude: Double
+}
+
+struct ServerRelay: Decodable {
+ let hostname: String
+ let active: Bool
+ let owned: Bool
+ let location: String
+ let provider: String
+ let ipv4AddrIn: IPv4Address
+ let weight: Int32
+ let includeInCountry: Bool
+}
+
+struct ServerWireguardTunnel: Decodable {
+ let ipv4Gateway: IPv4Address
+ let ipv6Gateway: IPv6Address
+ let publicKey: Data
+ let portRanges: [ClosedRange<UInt16>]
+ let relays: [ServerRelay]
+}
+
+struct ServerRelaysResponse: Decodable, RestResponse {
+ let locations: [String: ServerLocation]
+ let wireguard: [ServerWireguardTunnel]
+}
+
+struct PushWireguardKeyRequest: Encodable, RestPayload {
+ let pubkey: Data
+}
+
+struct WireguardAddressesResponse: Decodable, RestResponse {
+ let id: String
+ let pubkey: Data
+ let ipv4Address: IPAddressRange
+ let ipv6Address: IPAddressRange
+}
+
+struct ReplaceWireguardKeyRequest: Encodable, RestPayload {
+ let old: Data
+ let new: Data
+}
+
+struct CreateApplePaymentRequest: Encodable, RestPayload {
+ let receiptString: String
+}
+
+struct CreateApplePaymentResponse: Decodable, RestResponse {
+ let timeAdded: Int
+ let newExpiry: Date
+}