diff --git a/README.md b/README.md index d8b1a7f..38ea13d 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,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( + 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 = "" -let subscriptionOfferId = "" +let productIdentifier = "" +let subscriptionOfferID = "" let appAccountToken = "" // try! used for example purposes only diff --git a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift index 3058a5c..9b55555 100644 --- a/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift +++ b/Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift @@ -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 { - 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(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult { @@ -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(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. @@ -350,12 +363,12 @@ public class AppStoreServerAPIClient { 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) @@ -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, + ) + } +} 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) - } - } }