-
Notifications
You must be signed in to change notification settings - Fork 50
Implement Protocol-Based Testing with Sendable Conformance in AppStoreServerAPIClient #91
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
base: main
Are you sure you want to change the base?
Changes from all commits
4955478
b70823b
f032c55
0161db2
65104d8
db15153
acacc08
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -47,7 +47,8 @@ let encodedKey = try! String(contentsOfFile: "/path/to/key/SubscriptionKey_ABCDE | |
| let environment = AppStoreEnvironment.sandbox | ||
|
|
||
| // try! used for example purposes only | ||
| let client = try! AppStoreServerAPIClient(signingKey: encodedKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId, environment: environment) | ||
| let config = try! AppStoreServerAPIConfiguration(signingKeyPem: encodedKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId, environment: environment) | ||
| let client = AppStoreServerAPIClient(config: config) | ||
|
|
||
| let response = await client.requestTestNotification() | ||
| switch response { | ||
|
|
@@ -98,15 +99,17 @@ let encodedKey = try! String(contentsOfFile: "/path/to/key/SubscriptionKey_ABCDE | |
| let environment = AppStoreEnvironment.sandbox | ||
|
|
||
| // try! used for example purposes only | ||
| let client = try! AppStoreServerAPIClient(signingKey: encodedKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId, environment: environment) | ||
| let config = try! AppStoreServerAPIConfiguration(signingKeyPem: encodedKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId, environment: environment) | ||
| let client = AppStoreServerAPIClient(config: config) | ||
|
|
||
| let appReceipt = "MI..." | ||
| let transactionIdOptional = ReceiptUtility.extractTransactionId(appReceipt: appReceipt) | ||
| if let transactionId = transactionIdOptional { | ||
| var transactionHistoryRequest = TransactionHistoryRequest() | ||
| transactionHistoryRequest.sort = TransactionHistoryRequest.Order.ascending | ||
| transactionHistoryRequest.revoked = false | ||
| transactionHistoryRequest.productTypes = [TransactionHistoryRequest.ProductType.autoRenewable] | ||
| let transactionHistoryRequest = TransactionHistoryRequest( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems unrelated to to the proposed changes in this PR
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this triggered a warning, since |
||
| sort: .ascending, | ||
| revoked: false, | ||
| productTypes: [.autoRenewable] | ||
| ) | ||
|
|
||
| var response: HistoryResponse? | ||
| var transactions: [String] = [] | ||
|
|
@@ -137,8 +140,8 @@ let keyId = "ABCDEFGHIJ" | |
| let bundleId = "com.example" | ||
| let encodedKey = try! String(contentsOfFile: "/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") | ||
|
|
||
| let productId = "<product_id>" | ||
| let subscriptionOfferId = "<subscription_offer_id>" | ||
| let productIdentifier = "<product_id>" | ||
| let subscriptionOfferID = "<subscription_offer_id>" | ||
| let appAccountToken = "<app_account_token>" | ||
|
|
||
| // try! used for example purposes only | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,54 +7,66 @@ import AsyncHTTPClient | |
| import NIOHTTP1 | ||
| import NIOFoundationCompat | ||
|
|
||
| public class AppStoreServerAPIClient { | ||
| // MARK: - HTTP Client Protocol | ||
|
|
||
| /// Protocol for HTTP clients that work with App Store Server API | ||
| public protocol AppStoreHTTPClient: Sendable { | ||
| /// Execute an HTTP request | ||
| func execute( | ||
| _ request: HTTPClientRequest, | ||
| timeout: Duration | ||
| ) async throws -> HTTPClientResponse | ||
| } | ||
|
|
||
| public enum ConfigurationError: Error { | ||
| /// Xcode is not a supported environment for an AppStoreServerAPIClient | ||
| case invalidEnvironment | ||
| extension HTTPClient: AppStoreHTTPClient { | ||
| /// Execute HTTP request | ||
| /// - Parameters: | ||
| /// - request: HTTP request | ||
| /// - timeout: If execution is idle for longer than timeout then throw error | ||
| /// - Returns: HTTP response | ||
| public func execute( | ||
| _ request: HTTPClientRequest, | ||
| timeout: Duration | ||
| ) async throws -> HTTPClientResponse { | ||
| return try await self.execute(request, timeout: .init(timeout)) | ||
| } | ||
|
|
||
| private static let userAgent = "app-store-server-library/swift/3.1.0" | ||
| private static let productionUrl = "https://api.storekit.itunes.apple.com" | ||
| private static let sandboxUrl = "https://api.storekit-sandbox.itunes.apple.com" | ||
| private static let localTestingUrl = "https://local-testing-base-url" | ||
| private static let appStoreConnectAudience = "appstoreconnect-v1" | ||
|
|
||
| private let signingKey: P256.Signing.PrivateKey | ||
| private let keyId: String | ||
| private let issuerId: String | ||
| private let bundleId: String | ||
| private let url: String | ||
| private let client: HTTPClient | ||
| } | ||
|
|
||
| public final class AppStoreServerAPIClient: Sendable { | ||
|
|
||
| public static let userAgent = "app-store-server-library/swift/3.1.0" | ||
| public static let appStoreConnectAudience = "appstoreconnect-v1" | ||
| private let config: AppStoreServerAPIConfiguration | ||
| private let client: AppStoreHTTPClient | ||
| ///Create an App Store Server API client | ||
| /// | ||
| ///- Parameter signingKey: Your private key downloaded from App Store Connect | ||
| ///- Parameter issuerId: Your issuer ID from the Keys page in App Store Connect | ||
| ///- Parameter bundleId: Your app’s bundle ID | ||
| ///- Parameter environment: The environment to target | ||
| public init(signingKey: String, keyId: String, issuerId: String, bundleId: String, environment: AppStoreEnvironment) throws { | ||
StefanPrintezis marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| self.signingKey = try P256.Signing.PrivateKey(pemRepresentation: signingKey) | ||
| self.keyId = keyId | ||
| self.issuerId = issuerId | ||
| self.bundleId = bundleId | ||
| switch(environment) { | ||
| case .xcode: | ||
| throw ConfigurationError.invalidEnvironment | ||
| case .production: | ||
| self.url = AppStoreServerAPIClient.productionUrl | ||
| break | ||
| case .localTesting: | ||
| self.url = AppStoreServerAPIClient.localTestingUrl | ||
| break | ||
| case .sandbox: | ||
| self.url = AppStoreServerAPIClient.sandboxUrl | ||
| break | ||
| } | ||
| self.client = .init() | ||
| ///- Parameter config: The configuration for the client | ||
| ///- Parameter httpClient: The HTTP client to use for the client | ||
| public init( | ||
| config: AppStoreServerAPIConfiguration, | ||
| httpClient: AppStoreHTTPClient = HTTPClient.shared | ||
| ) { | ||
| self.config = config | ||
| self.client = httpClient | ||
| } | ||
|
|
||
| deinit { | ||
| try? self.client.syncShutdown() | ||
|
|
||
| @available(*, deprecated, message: "Use `init(config: httpClient:)` instead") | ||
| public init( | ||
| signingKey: String, | ||
| keyId: String, | ||
| issuerId: String, | ||
| bundleId: String, | ||
| environment: AppStoreEnvironment, | ||
| httpClient: AppStoreHTTPClient = HTTPClient.shared | ||
| ) throws { | ||
| self.config = try AppStoreServerAPIConfiguration( | ||
| signingKeyPem: signingKey, | ||
| keyId: keyId, | ||
| issuerId: issuerId, | ||
| bundleId: bundleId, | ||
| environment: environment | ||
| ) | ||
| self.client = httpClient | ||
| } | ||
|
|
||
| private func makeRequest<T: Encodable>(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult<Data> { | ||
|
|
@@ -65,7 +77,7 @@ public class AppStoreServerAPIClient { | |
| queryItems.append(URLQueryItem(name: parameter, value: val)) | ||
| } | ||
| } | ||
| var urlComponents = URLComponents(string: self.url) | ||
| var urlComponents = URLComponents(string: config.url) | ||
| urlComponents?.path = path | ||
| if !queryItems.isEmpty { | ||
| urlComponents?.queryItems = queryItems | ||
|
|
@@ -142,16 +154,17 @@ public class AppStoreServerAPIClient { | |
| } | ||
|
|
||
| private func generateToken() async throws -> String { | ||
| let keys = JWTKeyCollection() | ||
| let keys: JWTKeyCollection = .init() | ||
| let payload = AppStoreServerAPIJWT( | ||
| exp: .init(value: Date().addingTimeInterval(5 * 60)), // 5 minutes | ||
| iss: .init(value: self.issuerId), | ||
| bid: self.bundleId, | ||
| exp: .init(value: Date().addingTimeInterval(5 * 60)), // 5 minutes | ||
| iss: .init(value: config.issuerId), | ||
| bid: config.bundleId, | ||
| aud: .init(value: AppStoreServerAPIClient.appStoreConnectAudience), | ||
| iat: .init(value: Date()) | ||
| ) | ||
| try await keys.add(ecdsa: ECDSA.PrivateKey<P256>(backing: signingKey)) | ||
| return try await keys.sign(payload, header: ["typ": "JWT", "kid": .string(self.keyId)]) | ||
| try await keys.add(ecdsa: ECDSA.PrivateKey<P256>(backing: config.signingKey)) | ||
| return try await keys.sign( | ||
| payload, header: ["typ": "JWT", "kid": .string(config.keyId)]) | ||
| } | ||
|
|
||
| ///Uses a subscription’s product identifier to extend the renewal date for all of its eligible active subscribers. | ||
|
|
@@ -350,12 +363,12 @@ public class AppStoreServerAPIClient { | |
| private struct APIFetchError: Error {} | ||
| } | ||
|
|
||
| public enum APIResult<T> { | ||
| public enum APIResult<T: Sendable>: Sendable{ | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this the only change in this PR necessary to actually make this Sendable?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, there are more Sendable conformances added in the PR. |
||
| case success(response: T) | ||
| case failure(statusCode: Int?, rawApiError: Int64?, apiError: APIError?, errorMessage: String?, causedBy: Error?) | ||
| } | ||
|
|
||
| public enum APIError: Int64 { | ||
| public enum APIError: Int64, Sendable { | ||
| ///An error that indicates an invalid request. | ||
| /// | ||
| ///[GeneralBadRequestError](https://developer.apple.com/documentation/appstoreserverapi/generalbadrequesterror) | ||
|
|
@@ -663,3 +676,76 @@ public enum GetTransactionHistoryVersion: String { | |
| case v1 = "v1" | ||
| case v2 = "v2" | ||
| } | ||
|
|
||
| public struct AppStoreServerAPIConfiguration: Sendable { | ||
|
|
||
| public enum ConfigurationError: Error, Sendable { | ||
| /// Xcode is not a supported environment for an AppStoreServerAPIClient | ||
| case invalidEnvironment | ||
| case invalidSigningKey | ||
| } | ||
|
|
||
| public enum Environment: String, CaseIterable, Sendable { | ||
| case production | ||
| case sandbox | ||
| case localTesting | ||
|
|
||
| var baseUrl: String { | ||
| switch self { | ||
| case .production: | ||
| return "https://api.storekit.itunes.apple.com" | ||
| case .sandbox: | ||
| return "https://api.storekit-sandbox.itunes.apple.com" | ||
| case .localTesting: | ||
| return "https://local-testing-base-url" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public let url: String | ||
| public let keyId: String | ||
| public let issuerId: String | ||
| public let bundleId: String | ||
| public let signingKey: P256.Signing.PrivateKey | ||
|
|
||
| public init( | ||
| signingKey: P256.Signing.PrivateKey, | ||
| keyId: String, | ||
| issuerId: String, | ||
| bundleId: String, | ||
| environment: AppStoreEnvironment | ||
| ) throws { | ||
| let url = | ||
| switch environment { | ||
| case .xcode: | ||
| throw ConfigurationError.invalidEnvironment | ||
| case .production: | ||
| Environment.production.baseUrl | ||
| case .localTesting: | ||
| Environment.localTesting.baseUrl | ||
| case .sandbox: | ||
| Environment.sandbox.baseUrl | ||
| } | ||
| self.url = url | ||
| self.keyId = keyId | ||
| self.issuerId = issuerId | ||
| self.bundleId = bundleId | ||
| self.signingKey = signingKey | ||
| } | ||
|
|
||
| public init( | ||
| signingKeyPem: String, | ||
| keyId: String, | ||
| issuerId: String, | ||
| bundleId: String, | ||
| environment: AppStoreEnvironment, | ||
| ) throws { | ||
| try self.init( | ||
| signingKey: try P256.Signing.PrivateKey(pemRepresentation: signingKeyPem), | ||
| keyId: keyId, | ||
| issuerId: issuerId, | ||
| bundleId: bundleId, | ||
| environment: environment, | ||
| ) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is breaking out the configuration necessary for Sendable conformance? Why was this change made
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The main goal was use a mocked http client instead of
internal override func executeRequest. Sendable conformance is an addition, because why not :)