From 495547816fdabcc7cbff58ba2f038b2efe68c647 Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Wed, 18 Jun 2025 15:18:05 +0200 Subject: [PATCH 1/7] Conform AppStoreServerAPIClient to Sendable by making the class final Instead of initializing a http client pool, expose the httpClient through the initializer or default on .shared that way we can share the http pool with others Deinit is no longer needed since httpclient is exposed Create a configuration struct so we are able to throw when initializing config & have a non throwing client initializer --- .../AppStoreServerAPIClient.swift | 169 ++++++++++++------ 1 file changed, 118 insertions(+), 51 deletions(-) diff --git a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift index 3058a5c..61b4b37 100644 --- a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift +++ b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift @@ -7,54 +7,47 @@ 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 { - 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() - } - - deinit { - try? self.client.syncShutdown() + ///- 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 } private func makeRequest(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult { @@ -65,7 +58,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 +135,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(backing: signingKey)) - return try await keys.sign(payload, header: ["typ": "JWT", "kid": .string(self.keyId)]) + try await keys.add(ecdsa: ECDSA.PrivateKey(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. @@ -663,3 +657,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, + ) + } +} From b70823b0c7c8c69b87bf7d54170d110fb9081559 Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Thu, 19 Jun 2025 18:30:04 +0200 Subject: [PATCH 2/7] Instead of extending the client use a mocked http client to handle unit tests, since AppStoreServerAPIClient is a final Sendable class now --- .../AppStoreServerAPIClientTests.swift | 88 ++++++++++++++----- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift b/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift index a617493..f7fdfb6 100644 --- a/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift +++ b/Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift @@ -7,15 +7,35 @@ import AsyncHTTPClient import NIOHTTP1 import NIOFoundationCompat import JWTKit +import Crypto + +// MARK: - Mock HTTP Client + +final class MockHTTPClient: AppStoreHTTPClient, Sendable { + typealias Handler = @Sendable (HTTPClientRequest, Duration) async throws -> HTTPClientResponse + + let handler: Handler + + public init(handler: @escaping Handler) { + self.handler = handler + } + + func execute( + _ request: HTTPClientRequest, + timeout: Duration + ) async throws -> HTTPClientResponse { + return try await self.handler(request, timeout) + } +} final class AppStoreServerAPIClientTests: XCTestCase { - - typealias RequestVerifier = (HTTPClientRequest, Data?) throws -> () + typealias RequestVerifier = @Sendable (HTTPClientRequest, Data?) throws -> () public func testExtendRenewalDateForAllActiveSubscribers() async throws { let client = try getClientWithBody("resources/models/extendRenewalDateForAllActiveSubscribersResponse.json") { request, body in XCTAssertEqual(.POST, request.method) XCTAssertEqual("https://local-testing-base-url/inApps/v1/subscriptions/extend/mass", request.url) + let decodedJson = try! JSONSerialization.jsonObject(with: body!) as! [String: Any] XCTAssertEqual(45, decodedJson["extendByDays"] as! Int) XCTAssertEqual(1, decodedJson["extendReasonCode"] as! Int) @@ -34,8 +54,10 @@ final class AppStoreServerAPIClientTests: XCTestCase { TestingUtility.confirmCodableInternallyConsistent(extendRenewalDateRequest) + // Make the actual API call let response = await client.extendRenewalDateForAllActiveSubscribers(massExtendRenewalDateRequest: extendRenewalDateRequest) + // Verify the response guard case .success(let massExtendRenewalDateResponse) = response else { XCTAssertTrue(false) return @@ -627,11 +649,18 @@ final class AppStoreServerAPIClientTests: XCTestCase { public func testXcodeEnvironmentForAppStoreServerAPIClient() async throws { let key = getSigningKey() do { - let client = try AppStoreServerAPIClient(signingKey: key, keyId: "keyId", issuerId: "issuerId", bundleId: "com.example", environment: AppStoreEnvironment.xcode) + let config = try AppStoreServerAPIConfiguration( + signingKeyPem: key, + keyId: "keyId", + issuerId: "issuerId", + bundleId: "com.example", + environment: AppStoreEnvironment.xcode + ) + let _ = AppStoreServerAPIClient(config: config) XCTAssertTrue(false) return } catch (let e) { - XCTAssertEqual(AppStoreServerAPIClient.ConfigurationError.invalidEnvironment, e as! AppStoreServerAPIClient.ConfigurationError) + XCTAssertEqual(AppStoreServerAPIConfiguration.ConfigurationError.invalidEnvironment, e as! AppStoreServerAPIConfiguration.ConfigurationError) } } @@ -722,30 +751,41 @@ final class AppStoreServerAPIClientTests: XCTestCase { private func getAppStoreServerAPIClient(_ body: String, _ status: HTTPResponseStatus, _ requestVerifier: RequestVerifier?) throws -> AppStoreServerAPIClient { let key = getSigningKey() - let client = try AppStoreServerAPIClientTest(signingKey: key, keyId: "keyId", issuerId: "issuerId", bundleId: "com.example", environment: AppStoreEnvironment.localTesting) { request, requestBody in - try requestVerifier.map { try $0(request, requestBody) } + let config = try AppStoreServerAPIConfiguration( + signingKey: try P256.Signing.PrivateKey(pemRepresentation: key), + keyId: "keyId", + issuerId: "issuerId", + bundleId: "com.example", + environment: AppStoreEnvironment.localTesting + ) + + // Create a mock HTTP client for testing + let mockHTTPClient = MockHTTPClient(handler: { [requestVerifier] request, timeout in + // If a request verifier is provided, call it + if let verifier = requestVerifier { + if let requestBody = request.body { + var collectedBody = try await requestBody.collect(upTo: 1024 * 1024) + let bodyData = collectedBody.readData(length: collectedBody.readableBytes) + try verifier(request, bodyData) + } else { + try verifier(request, nil) + } + } + + // Return the mock response let headers = [("Content-Type", "application/json")] - let bufferedBody = HTTPClientResponse.Body.bytes(.init(string: body)) - return HTTPClientResponse(version: .http1_1, status: status, headers: HTTPHeaders(headers), body: bufferedBody) - } - return client; + return HTTPClientResponse( + version: .http1_1, + status: status, + headers: HTTPHeaders(headers), + body: .bytes(.init(string: body)) + ) + }) + + return AppStoreServerAPIClient(config: config, httpClient: mockHTTPClient) } private func getSigningKey() -> String { return TestingUtility.readFile("resources/certs/testSigningKey.p8") } - - class AppStoreServerAPIClientTest: AppStoreServerAPIClient { - - private var requestOverride: ((HTTPClientRequest, Data?) throws -> HTTPClientResponse)? - - public init(signingKey: String, keyId: String, issuerId: String, bundleId: String, environment: AppStoreEnvironment, requestOverride: @escaping (HTTPClientRequest, Data?) throws -> HTTPClientResponse) throws { - try super.init(signingKey: signingKey, keyId: keyId, issuerId: issuerId, bundleId: bundleId, environment: environment) - self.requestOverride = requestOverride - } - - internal override func executeRequest(_ urlRequest: HTTPClientRequest, _ body: Data?) async throws -> HTTPClientResponse { - return try requestOverride!(urlRequest, body) - } - } } From f032c554e4e67a6a86f10933ad419c665e85c64c Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Fri, 20 Jun 2025 09:55:20 +0200 Subject: [PATCH 3/7] Update readme to use the new configuration struct --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d8b1a7f..f4e1ee0 100644 --- a/README.md +++ b/README.md @@ -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,7 +99,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 appReceipt = "MI..." let transactionIdOptional = ReceiptUtility.extractTransactionId(appReceipt: appReceipt) From 0161db29b832c91e029b291cc0cc0d4d1f361189 Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Fri, 20 Jun 2025 09:55:57 +0200 Subject: [PATCH 4/7] Use modern initialization syntax in readme for transactionHistoryRequest --- README.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f4e1ee0..17b31a3 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,11 @@ 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( + sort: .ascending, + revoked: false, + productTypes: [.autoRenewable] + ) var response: HistoryResponse? var transactions: [String] = [] From 65104d845b37b08d0d595fe762addc3e5c9d69cb Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Fri, 20 Jun 2025 09:56:22 +0200 Subject: [PATCH 5/7] Fix variable name inconsistency in readme.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 17b31a3..38ea13d 100644 --- a/README.md +++ b/README.md @@ -140,8 +140,8 @@ let keyId = "ABCDEFGHIJ" let bundleId = "com.example" let encodedKey = try! String(contentsOfFile: "/path/to/key/SubscriptionKey_ABCDEFGHIJ.p8") -let productId = "" -let subscriptionOfferId = "" +let productIdentifier = "" +let subscriptionOfferID = "" let appAccountToken = "" // try! used for example purposes only From db15153c3a5ff556a3a8383ac1aee3cc52eedf82 Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Fri, 20 Jun 2025 10:35:46 +0200 Subject: [PATCH 6/7] Add backwards client compatible initializer as deprecated --- .../AppStoreServerAPIClient.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift index 61b4b37..d49858c 100644 --- a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift +++ b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift @@ -49,6 +49,25 @@ public final class AppStoreServerAPIClient: Sendable { self.config = config self.client = httpClient } + + @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(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult { do { From acacc08267ce006b555430c814841c92c88f440f Mon Sep 17 00:00:00 2001 From: Stefan Printezis Date: Fri, 20 Jun 2025 14:20:51 +0200 Subject: [PATCH 7/7] Conform APIResult & APIError to Sendable --- Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift index d49858c..9b55555 100644 --- a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift +++ b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift @@ -363,12 +363,12 @@ public final class AppStoreServerAPIClient: Sendable { private struct APIFetchError: Error {} } -public enum APIResult { +public enum APIResult: Sendable{ 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)