Skip to content
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Collaborator

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


let response = await client.requestTestNotification()
switch response {
Expand Down Expand Up @@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems unrelated to to the proposed changes in this PR

sort: .ascending,
revoked: false,
productTypes: [.autoRenewable]
)

var response: HistoryResponse?
var transactions: [String] = []
Expand Down Expand Up @@ -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
Expand Down
169 changes: 118 additions & 51 deletions Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T: Encodable>(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult<Data> {
Expand All @@ -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
Expand Down Expand Up @@ -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<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.
Expand Down Expand Up @@ -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,
)
}
}
88 changes: 64 additions & 24 deletions Tests/AppStoreServerLibraryTests/AppStoreServerAPIClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}
}