Skip to content

Commit 4fd52d1

Browse files
Merge pull request #101 from alexanderjordanbaker/retention-messaging-1.1
Add support for the Retention Messaging API 1.0-1.1 https://developer…
2 parents 43d6c85 + 302e1a6 commit 4fd52d1

31 files changed

+1172
-15
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Apple App Store Server Swift Library
2-
The Swift server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi) and [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications). Also available in [Java](https://github.com/apple/app-store-server-library-java), [Python](https://github.com/apple/app-store-server-library-python), and [Node.js](https://github.com/apple/app-store-server-library-node).
2+
The Swift server library for the [App Store Server API](https://developer.apple.com/documentation/appstoreserverapi), [App Store Server Notifications](https://developer.apple.com/documentation/appstoreservernotifications), and the [Retention Messaging API](https://developer.apple.com/documentation/retentionmessaging). Also available in [Java](https://github.com/apple/app-store-server-library-java), [Python](https://github.com/apple/app-store-server-library-python), and [Node.js](https://github.com/apple/app-store-server-library-node).
33

44
## Table of Contents
55
1. [Installation](#installation)

Sources/AppStoreServerLibrary/AppStoreServerAPIClient.swift

Lines changed: 194 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,24 @@ public class AppStoreServerAPIClient {
5757
try? self.client.syncShutdown()
5858
}
5959

60+
private enum RequestBody {
61+
case encodable(any Encodable)
62+
case raw(Data, contentType: String)
63+
}
64+
6065
private func makeRequest<T: Encodable>(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: T?) async -> APIResult<Data> {
66+
if let b = body {
67+
return await makeRequest(path: path, method: method, queryParameters: queryParameters, body: .encodable(b))
68+
} else {
69+
return await makeRequest(path: path, method: method, queryParameters: queryParameters, body: nil)
70+
}
71+
}
72+
73+
private func makeRequest(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: Data, contentType: String) async -> APIResult<Data> {
74+
return await makeRequest(path: path, method: method, queryParameters: queryParameters, body: .raw(body, contentType: contentType))
75+
}
76+
77+
private func makeRequest(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: RequestBody?) async -> APIResult<Data> {
6178
do {
6279
var queryItems: [URLQueryItem] = []
6380
for (parameter, values) in queryParameters {
@@ -84,11 +101,20 @@ public class AppStoreServerAPIClient {
84101

85102
let requestBody: Data?
86103
if let b = body {
87-
let jsonEncoder = getJsonEncoder()
88-
let encodedBody = try jsonEncoder.encode(b)
89-
requestBody = encodedBody
90-
urlRequest.body = .bytes(.init(data: encodedBody))
91-
urlRequest.headers.add(name: "Content-Type", value: "application/json")
104+
let data: Data
105+
let contentType: String
106+
switch b {
107+
case .encodable(let encodable):
108+
let jsonEncoder = getJsonEncoder()
109+
data = try jsonEncoder.encode(encodable)
110+
contentType = "application/json"
111+
case .raw(let rawData, let ct):
112+
data = rawData
113+
contentType = ct
114+
}
115+
requestBody = data
116+
urlRequest.body = .bytes(.init(data: data))
117+
urlRequest.headers.add(name: "Content-Type", value: contentType)
92118
} else {
93119
requestBody = nil
94120
}
@@ -140,7 +166,17 @@ public class AppStoreServerAPIClient {
140166
return APIResult.failure(statusCode: statusCode, rawApiError: rawApiError, apiError: apiError, errorMessage: errorMessage, causedBy: causedBy)
141167
}
142168
}
143-
169+
170+
private func makeRequestWithoutResponseBody(path: String, method: HTTPMethod, queryParameters: [String: [String]], body: Data, contentType: String) async -> APIResult<Void> {
171+
let response = await makeRequest(path: path, method: method, queryParameters: queryParameters, body: body, contentType: contentType)
172+
switch response {
173+
case .success:
174+
return APIResult.success(response: ())
175+
case .failure(let statusCode, let rawApiError, let apiError, let errorMessage, let causedBy):
176+
return APIResult.failure(statusCode: statusCode, rawApiError: rawApiError, apiError: apiError, errorMessage: errorMessage, causedBy: causedBy)
177+
}
178+
}
179+
144180
private func generateToken() async throws -> String {
145181
let keys = JWTKeyCollection()
146182
let payload = AppStoreServerAPIJWT(
@@ -335,7 +371,87 @@ public class AppStoreServerAPIClient {
335371
public func setAppAccountToken(originalTransactionId: String, updateAppAccountTokenRequest: UpdateAppAccountTokenRequest) async -> APIResult<Void> {
336372
return await makeRequestWithoutResponseBody(path: "/inApps/v1/transactions/" + originalTransactionId + "/appAccountToken", method: .PUT, queryParameters: [:], body: updateAppAccountTokenRequest)
337373
}
338-
374+
375+
///Upload an image to use for retention messaging.
376+
///
377+
///- Parameter imageIdentifier: A UUID you provide to uniquely identify the image you upload.
378+
///- Parameter image: The image file to upload.
379+
///- Returns: Success, or information about the failure
380+
///[Upload Image](https://developer.apple.com/documentation/retentionmessaging/upload-image)
381+
public func uploadImage(imageIdentifier: UUID, image: Data) async -> APIResult<Void> {
382+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/image/" + imageIdentifier.uuidString, method: .PUT, queryParameters: [:], body: image, contentType: "image/png")
383+
}
384+
385+
///Delete a previously uploaded image.
386+
///
387+
///- Parameter imageIdentifier: The identifier of the image to delete.
388+
///- Returns: Success, or information about the failure
389+
///[Delete Image](https://developer.apple.com/documentation/retentionmessaging/delete-image)
390+
public func deleteImage(imageIdentifier: UUID) async -> APIResult<Void> {
391+
let request: String? = nil
392+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/image/" + imageIdentifier.uuidString, method: .DELETE, queryParameters: [:], body: request)
393+
}
394+
395+
///Get the image identifier and state for all uploaded images.
396+
///
397+
///- Returns: A response that contains status information for all images.
398+
///[Get Image List](https://developer.apple.com/documentation/retentionmessaging/get-image-list)
399+
public func getImageList() async -> APIResult<GetImageListResponse> {
400+
let request: String? = nil
401+
return await makeRequestWithResponseBody(path: "/inApps/v1/messaging/image/list", method: .GET, queryParameters: [:], body: request)
402+
}
403+
404+
///Upload a message to use for retention messaging.
405+
///
406+
///- Parameter messageIdentifier: A UUID you provide to uniquely identify the message you upload.
407+
///- Parameter uploadMessageRequestBody: The message text to upload.
408+
///- Returns: Success, or information about the failure
409+
///[Upload Message](https://developer.apple.com/documentation/retentionmessaging/upload-message)
410+
public func uploadMessage(messageIdentifier: UUID, uploadMessageRequestBody: UploadMessageRequestBody) async -> APIResult<Void> {
411+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/message/" + messageIdentifier.uuidString, method: .PUT, queryParameters: [:], body: uploadMessageRequestBody)
412+
}
413+
414+
///Delete a previously uploaded message.
415+
///
416+
///- Parameter messageIdentifier: The identifier of the message to delete.
417+
///- Returns: Success, or information about the failure
418+
///[Delete Message](https://developer.apple.com/documentation/retentionmessaging/delete-message)
419+
public func deleteMessage(messageIdentifier: UUID) async -> APIResult<Void> {
420+
let request: String? = nil
421+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/message/" + messageIdentifier.uuidString, method: .DELETE, queryParameters: [:], body: request)
422+
}
423+
424+
///Get the message identifier and state of all uploaded messages.
425+
///
426+
///- Returns: A response that contains status information for all messages, or information about the failure
427+
///[Get Message List](https://developer.apple.com/documentation/retentionmessaging/get-message-list)
428+
public func getMessageList() async -> APIResult<GetMessageListResponse> {
429+
let request: String? = nil
430+
return await makeRequestWithResponseBody(path: "/inApps/v1/messaging/message/list", method: .GET, queryParameters: [:], body: request)
431+
}
432+
433+
///Configure a default message for a specific product in a specific locale.
434+
///
435+
///- Parameter productId: The product identifier for the default configuration.
436+
///- Parameter locale: The locale for the default configuration.
437+
///- Parameter defaultConfigurationRequest: The request body that includes the message identifier to configure as the default message.
438+
///- Returns: Success, or information about the failure
439+
///[Configure Default Message](https://developer.apple.com/documentation/retentionmessaging/configure-default-message)
440+
public func configureDefaultMessage(productId: String, locale: String, defaultConfigurationRequest: DefaultConfigurationRequest) async -> APIResult<Void> {
441+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/default/" + productId + "/" + locale, method: .PUT, queryParameters: [:], body: defaultConfigurationRequest)
442+
}
443+
444+
///Delete a default message for a product in a locale.
445+
///
446+
///- Parameter productId: The product ID of the default message configuration.
447+
///- Parameter locale: The locale of the default message configuration.
448+
///- Returns: Success, or information about the failure
449+
///[Delete Default Message](https://developer.apple.com/documentation/retentionmessaging/delete-default-message)
450+
public func deleteDefaultMessage(productId: String, locale: String) async -> APIResult<Void> {
451+
let request: String? = nil
452+
return await makeRequestWithoutResponseBody(path: "/inApps/v1/messaging/default/" + productId + "/" + locale, method: .DELETE, queryParameters: [:], body: request)
453+
}
454+
339455
internal struct AppStoreServerAPIJWT: JWTPayload, Equatable {
340456
var exp: ExpirationClaim
341457
var iss: IssuerClaim
@@ -562,6 +678,31 @@ public enum APIError: Int64 {
562678
///[AppTransactionIdNotSupportedError](https://developer.apple.com/documentation/appstoreserverapi/apptransactionidnotsupportederror)
563679
case appTransactionIdNotSupported = 4000048
564680

681+
///An error that indicates the image that's uploading is invalid.
682+
///
683+
///[InvalidImageError](https://developer.apple.com/documentation/retentionmessaging/invalidimageerror)
684+
case invalidImage = 4000161
685+
686+
///An error that indicates the header text is too long.
687+
///
688+
///[HeaderTooLongError](https://developer.apple.com/documentation/retentionmessaging/headertoolongerror)
689+
case headerTooLong = 4000162
690+
691+
///An error that indicates the body text is too long.
692+
///
693+
///[BodyTooLongError](https://developer.apple.com/documentation/retentionmessaging/bodytoolongerror)
694+
case bodyTooLong = 4000163
695+
696+
///An error that indicates the locale is invalid.
697+
///
698+
///[InvalidLocaleError](https://developer.apple.com/documentation/retentionmessaging/invalidlocaleerror)
699+
case invalidLocale = 4000164
700+
701+
///An error that indicates the alternative text for an image is too long.
702+
///
703+
///[AltTextTooLongError](https://developer.apple.com/documentation/retentionmessaging/alttexttoolongerror)
704+
case altTextTooLong = 4000175
705+
565706
///An error that indicates the app account token value is not a valid UUID.
566707
///
567708
///[InvalidAppAccountTokenUUIDError](https://developer.apple.com/documentation/appstoreserverapi/invalidappaccounttokenuuiderror)
@@ -592,7 +733,32 @@ public enum APIError: Int64 {
592733
///[FamilySharedSubscriptionExtensionIneligibleError](https://developer.apple.com/documentation/appstoreserverapi/familysharedsubscriptionextensionineligibleerror)
593734
case familySharedSubscriptionExtensionIneligible = 4030007
594735

595-
///An error that indicates the App Store account wasn’t found.
736+
///An error that indicates when you reach the maximum number of uploaded images.
737+
///
738+
///[MaximumNumberOfImagesReachedError](https://developer.apple.com/documentation/retentionmessaging/maximumnumberofimagesreachederror)
739+
case maximumNumberOfImagesReached = 4030014
740+
741+
///An error that indicates when you reach the maximum number of uploaded messages.
742+
///
743+
///[MaximumNumberOfMessagesReachedError](https://developer.apple.com/documentation/retentionmessaging/maximumnumberofmessagesreachederror)
744+
case maximumNumberOfMessagesReached = 4030016
745+
746+
///An error that indicates the message isn't in the approved state, so you can't configure it as a default message.
747+
///
748+
///[MessageNotApprovedError](https://developer.apple.com/documentation/retentionmessaging/messagenotapprovederror)
749+
case messageNotApproved = 4030017
750+
751+
///An error that indicates the image isn't in the approved state, so you can't configure it as part of a default message.
752+
///
753+
///[ImageNotApprovedError](https://developer.apple.com/documentation/retentionmessaging/imagenotapprovederror)
754+
case imageNotApproved = 4030018
755+
756+
///An error that indicates the image is currently in use as part of a message, so you can't delete it.
757+
///
758+
///[ImageInUseError](https://developer.apple.com/documentation/retentionmessaging/imageinuseerror)
759+
case imageInUse = 4030019
760+
761+
///An error that indicates the App Store account wasn't found.
596762
///
597763
///[AccountNotFoundError](https://developer.apple.com/documentation/appstoreserverapi/accountnotfounderror)
598764
case accountNotFound = 4040001
@@ -642,6 +808,26 @@ public enum APIError: Int64 {
642808
///[TransactionIdNotFoundError](https://developer.apple.com/documentation/appstoreserverapi/transactionidnotfounderror)
643809
case transactionIdNotFound = 4040010
644810

811+
///An error that indicates the system can't find the image identifier.
812+
///
813+
///[ImageNotFoundError](https://developer.apple.com/documentation/retentionmessaging/imagenotfounderror)
814+
case imageNotFound = 4040014
815+
816+
///An error that indicates the system can't find the message identifier.
817+
///
818+
///[MessageNotFoundError](https://developer.apple.com/documentation/retentionmessaging/messagenotfounderror)
819+
case messageNotFound = 4040015
820+
821+
///An error that indicates the image identifier already exists.
822+
///
823+
///[ImageAlreadyExistsError](https://developer.apple.com/documentation/retentionmessaging/imagealreadyexistserror)
824+
case imageAlreadyExists = 4090000
825+
826+
///An error that indicates the message identifier already exists.
827+
///
828+
///[MessageAlreadyExistsError](https://developer.apple.com/documentation/retentionmessaging/messagealreadyexistserror)
829+
case messageAlreadyExists = 4090001
830+
645831
///An error that indicates that the request exceeded the rate limit.
646832
///
647833
///[RateLimitExceededError](https://developer.apple.com/documentation/appstoreserverapi/ratelimitexceedederror)

Sources/AppStoreServerLibrary/ChainVerifier.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ class ChainVerifier {
6767
do {
6868
let leafCertificate = try Certificate(derEncoded: Array(leaf_der_enocded))
6969
let intermediateCertificate = try Certificate(derEncoded: Array(intermeidate_der_encoded))
70-
let validationTime = !onlineVerification && decodedBody.signedDate != nil ? decodedBody.signedDate! : getDate()
70+
let validationTime = !onlineVerification && decodedBody.signedDateOptional != nil ? decodedBody.signedDateOptional! : getDate()
7171

7272
let verificationResult = await verifyChain(leaf: leafCertificate, intermediate: intermediateCertificate, online: onlineVerification, validationTime: validationTime)
7373
switch verificationResult {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2025 Apple Inc. Licensed under MIT License.
2+
3+
import Foundation
4+
5+
///A switch-plan message and product ID you provide in a real-time response to your Get Retention Message endpoint.
6+
///
7+
///[alternateProduct](https://developer.apple.com/documentation/retentionmessaging/alternateproduct)
8+
public struct AlternateProduct: Decodable, Encodable, Hashable, Sendable {
9+
10+
public init(messageIdentifier: UUID? = nil, productId: String? = nil) {
11+
self.messageIdentifier = messageIdentifier
12+
self.productId = productId
13+
}
14+
15+
///The message identifier of the text to display in the switch-plan retention message.
16+
///
17+
///[messageIdentifier](https://developer.apple.com/documentation/retentionmessaging/messageidentifier)
18+
public var messageIdentifier: UUID?
19+
20+
///The product identifier of the subscription the retention message suggests for your customer to switch to.
21+
///
22+
///[productId](https://developer.apple.com/documentation/retentionmessaging/productid)
23+
public var productId: String?
24+
}

Sources/AppStoreServerLibrary/Models/AppTransaction.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public struct AppTransaction: DecodedSignedData, Decodable, Encodable, Hashable,
109109
///The date that the App Store signed the JWS app transaction.
110110
///
111111
///[signedDate](https://developer.apple.com/documentation/storekit/apptransaction/3954449-signeddate)
112-
public var signedDate: Date? {
112+
public var signedDateOptional: Date? {
113113
receiptCreationDate
114114
}
115115

0 commit comments

Comments
 (0)