Upgrade to Pro — share decks privately, control downloads, hide ads and more …

[SwiftConf '24] Shipping your apps should be fa...

[SwiftConf '24] Shipping your apps should be fast and easy

Pol Piella Abadia

August 08, 2024
Tweet

More Decks by Pol Piella Abadia

Other Decks in Programming

Transcript

  1. Translations Traducciones ت ر ج م ا ت Переклади Çeviriler

    Übersetzungen Traduzioni Traduccions Oversettelser Übersetzungen Tłumaczenia Μεταφράσεις Vertalingen Traduçoes Bản dịch การแปล 翻译
  2. Translations Traducciones ت ر ج م ا ت Переклади Çeviriler

    Übersetzungen Traduzioni Traduccions Oversettelser Übersetzungen Tłumaczenia Μεταφράσεις Vertalingen Traduçoes Bản dịch การแปล 翻译 40 Locales
  3. Did you just submit a new update to the App

    Store? Don ’ t forget to add the Promotional Text 😊
  4. IAP TestFlight Xcode Cloud Users & Roles Provisioning Customer Reviews

    Performance Reporting App Metadata App Clips
  5. Using the App Store Connect API App Store Connect API

    A s t e p b y s te p gui de A s t e p b y s te p gui de
  6. import SwiftJWT import Foundation struct JWTClaims: Claims { let iss:

    String let iat: Date? let exp: Date? let aud: String } final class TokenProvider { private let keyId: String private let issuerId: String private let privateKey: String private let now: () -> Date init(keyId: String, issuerId: String, privateKey: String, now: @escaping () -> Date = Date.init) { self.keyId = keyId self.issuerId = issuerId self.privateKey = privateKey self.now = now } func generateToken() throws - > String { let header = Header(typ: "JWT", kid: keyId) let claims = JWTClaims( iss: issuerId, iat: now(), exp: now() + 60 * 60 * 12, aud: "appstoreconnect-v1" ) var jwt = JWT(header: header, claims: claims) let keyData = Data(base64Encoded: privateKey) return try jwt.sign(using: .es256(privateKey: privateKey.data(using: .utf8)!)) } }
  7. import SwiftJWT import Foundation struct JWTClaims: Claims { let iss:

    String let iat: Date? let exp: Date? let aud: String } final class TokenProvider { private let keyId: String private let issuerId: String private let privateKey: String private let now: () -> Date init(keyId: String, issuerId: String, privateKey: String, now: @escaping () -> Date = Date.init) { self.keyId = keyId self.issuerId = issuerId self.privateKey = privateKey self.now = now } func generateToken() throws - > String { let header = Header(typ: "JWT", kid: keyId) let claims = JWTClaims( iss: issuerId, iat: now(), exp: now() + 60 * 60 * 12, aud: "appstoreconnect-v1" ) var jwt = JWT(header: header, claims: claims) let keyData = Data(base64Encoded: privateKey) return try jwt.sign(using: .es256(privateKey: privateKey.data(using: .utf8)!)) } }
  8. import SwiftJWT import Foundation struct JWTClaims: Claims { let iss:

    String let iat: Date? let exp: Date? let aud: String } final class TokenProvider { private let keyId: String private let issuerId: String private let privateKey: String private let now: () -> Date init(keyId: String, issuerId: String, privateKey: String, now: @escaping () -> Date = Date.init) { self.keyId = keyId self.issuerId = issuerId self.privateKey = privateKey self.now = now } func generateToken() throws - > String { let header = Header(typ: "JWT", kid: keyId) let claims = JWTClaims( iss: issuerId, iat: now(), exp: now() + 60 * 60 * 12, aud: "appstoreconnect-v1" ) var jwt = JWT(header: header, claims: claims) let keyData = Data(base64Encoded: privateKey) return try jwt.sign(using: .es256(privateKey: privateKey.data(using: .utf8)!)) } }
  9. #3 Make a request #3 Make a request final class

    AppStoreConnectClient { private let session: URLSession private let tokenProvider: () - > String private let jsonDecoder: JSONDecoder private let baseURL = URL(string: "https: // api.appstoreconnect.apple.com/v1")! init(session: URLSession = .shared, tokenProvider: @escaping () - > String, jsonDecoder: JSONDecoder = .init()) { self.session = session self.tokenProvider = tokenProvider self.jsonDecoder = jsonDecoder } func fetchApps() async throws - > [App] { let url = baseURL.appendingPathComponent("apps") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(tokenProvider())", forHTTPHeaderField: "Authorization") let (data, _) = try await session.data(for: request) let response = try jsonDecoder.decode(AppsResponse.self, from: data) return response.data } }
  10. Tha t w i l l s a ve y

    ou a b u n ch o f t i m e a n d e ffo r t! Tha t w i l l s a ve y ou a b u n ch o f t i m e a n d e ffo r t! PRO TIPS PRO TIPS
  11. { "openapi" : "3.0.1", "info" : { "title" : "App

    Store Connect API", "version" : "3.5", "x-platform" : "app_store_connect_api" }, "servers" : [ { "url" : "https: // api.appstoreconnect.apple.com/" } ], "paths" : { "/v1/actors" : { "get" : { "tags" : [ "Actors" ], "operationId" : "actors-get_collection", "parameters" : [ { "name" : "filter[id]", "in" : "query", "description" : "filter by id(s)", "schema" : { "type" : "array", "items" : { "type" : "string" } },
  12. final class AppStoreConnectClient { private let session: URLSession private let

    tokenProvider: () - > String private let jsonDecoder: JSONDecoder private let baseURL = URL(string: "https: // api.appstoreconnect.apple.com/v1")! init(session: URLSession = .shared, tokenProvider: @escaping () - > String, jsonDecoder: JSONDecoder = .init()) { self.session = session self.tokenProvider = tokenProvider self.jsonDecoder = jsonDecoder } func fetchApps() async throws - > [App] { let url = baseURL.appendingPathComponent("apps") var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(tokenProvider())", forHTTPHeaderField: "Authorization") let (data, _) = try await session.data(for: request) let response = try jsonDecoder.decode(AppsResponse.self, from: data) return response.data } }
  13. import AppStoreConnect_Swift_SDK final class AppStoreConnectClient { private let provider: APIProvider

    init(provider: APIProvider) { self.provider = provider } func fetchApps() async throws - > [App] { try await provider .request(APIEndpoint.v1.apps.get()) .data } } Net wor k i ng c l i e n t tha t h a n d l es a ut h en t ic a tio n
  14. import AppStoreConnect_Swift_SDK final class AppStoreConnectClient { private let provider: APIProvider

    init(provider: APIProvider) { self.provider = provider } func fetchApps() async throws - > [App] { try await provider .request(APIEndpoint.v1.apps.get()) .data } } Ty p e-sa fe e n d p o in t s , bu il t -in deco di n g
  15. import AppStoreConnect_Swift_SDK final class AppStoreConnectClient { private let provider: APIProvider

    init(provider: APIProvider) { self.provider = provider } func fetchApps() async throws - > [App] { try await provider .request(APIEndpoint.v1.apps.get()) .data } } Aut o -ge nera te d C o da b l e m o d el s fo r re s p o n se s
  16. Request what you need! Request what you need! import AppStoreConnect_Swift_SDK

    final class AppStoreConnectClient { private let provider: APIProvider init(provider: APIProvider) { self.provider = provider } func fetchApps() async throws - > [App] { try await provider .request(APIEndpoint.v1.apps.get()) .data } } import AppStoreConnect_Swift_SDK final class AppStoreConnectClient { private let provider: APIProvider init(provider: APIProvider) { self.provider = provider } func fetchApps() async throws - > [App] { let endpoint = APIEndpoint.v1.apps .get( parameters: .init( fieldsApps: [.name, .bundleID], limit: 10 ) ) return try await provider.request(endpoint).data } }
  17. Automate your processes An d t ha n k m

    e l a t er! An d t ha n k m e l a t er! Automate your processes
  18. Did you just submit a new update to the App

    Store? Don ’ t forget to add the Promotional Text 😊
  19. func createVersion(forApp app: String, withPlatform platform: Platform, andVersion version: String)

    async throws - > String { let appData = AppStoreVersionCreateRequest.Data.Relationships.App.Data( type: .apps, id: app ) let app = AppStoreVersionCreateRequest.Data.Relationships.App(data: appData) let relationships = AppStoreVersionCreateRequest.Data.Relationships(app: .init(data: appData)) let attributes = AppStoreVersionCreateRequest.Data.Attributes(platform: platform, versionString: version) let data = AppStoreVersionCreateRequest.Data( type: .appStoreVersions, attributes: attributes, relationships: relationships ) let versionRequest = APIEndpoint .v1 .appStoreVersions .post(.init(data: data)) let response = try await provider.request(versionRequest).data return response.id }
  20. func createVersion(forApp app: String, withPlatform platform: Platform, andVersion version: String)

    async throws - > String { let appData = AppStoreVersionCreateRequest.Data.Relationships.App.Data( type: .apps, id: app ) let app = AppStoreVersionCreateRequest.Data.Relationships.App(data: appData) let relationships = AppStoreVersionCreateRequest.Data.Relationships(app: .init(data: appData)) let attributes = AppStoreVersionCreateRequest.Data.Attributes(platform: platform, versionString: version) let data = AppStoreVersionCreateRequest.Data( type: .appStoreVersions, attributes: attributes, relationships: relationships ) let versionRequest = APIEndpoint .v1 .appStoreVersions .post(.init(data: data)) let response = try await provider.request(versionRequest).data return response.id }
  21. func createVersion(forApp app: String, withPlatform platform: Platform, andVersion version: String)

    async throws - > String { let appData = AppStoreVersionCreateRequest.Data.Relationships.App.Data( type: .apps, id: app ) let app = AppStoreVersionCreateRequest.Data.Relationships.App(data: appData) let relationships = AppStoreVersionCreateRequest.Data.Relationships(app: .init(data: appData)) let attributes = AppStoreVersionCreateRequest.Data.Attributes(platform: platform, versionString: version) let data = AppStoreVersionCreateRequest.Data( type: .appStoreVersions, attributes: attributes, relationships: relationships ) let versionRequest = APIEndpoint .v1 .appStoreVersions .post(.init(data: data)) let response = try await provider.request(versionRequest).data return response.id }
  22. func getLatestLiveVersionPromotionalText(forApp app: String, withPlatform platform: Platform) async throws ->

    [AppStoreLanguage: String] { let platform: APIEndpoint.V1.Apps.WithID.AppStoreVersions.GetParameters.FilterPlatform = { switch platform { case .ios: return .ios case .macOs: return .macOs case .tvOs: return .tvOs case .visionOs: return .visionOs } }() let endpoint = APIEndpoint.v1.apps.id(app).appStoreVersions .get( parameters: .init( filterAppVersionState: [.processingForDistribution, .readyForDistribution, .pendingAppleRelease], filterPlatform: [platform], fieldsAppStoreVersionLocalizations: [.promotionalText, .locale], limit: 1, include: [.appStoreVersionLocalizations] ) ) let response = try await provider.request(endpoint) let localizations = response.included ?. compactMap { item in if case .appStoreVersionLocalization(let localization) = item { return localization } return nil } let easyAccessLocalizations: [String: AppStoreVersionLocalization] = localizations? .reduce(into: [String: AppStoreVersionLocalization](), { $0[$1.id] = $1 }) ?? [:] let latestVersionLocalizations = response.data.first ? . relationships ?. appStoreVersionLocalizations ? . data? .reduce(into: [AppStoreLanguage: String]()) { partialResult, localization in if let matchedLocalization = easyAccessLocalizations[localization.id], let promotionalText = matchedLocalization.attributes ?. promotionalText, let language = AppStoreLanguage(rawValue: matchedLocalization.attributes ?. locale ? ? "") { partialResult[language] = promotionalText } } return latestVersionLocalizations ?? [:] }
  23. func getLatestLiveVersionPromotionalText(forApp app: String, withPlatform platform: Platform) async throws ->

    [AppStoreLanguage: String] { let platform: APIEndpoint.V1.Apps.WithID.AppStoreVersions.GetParameters.FilterPlatform = { switch platform { case .ios: return .ios case .macOs: return .macOs case .tvOs: return .tvOs case .visionOs: return .visionOs } }() let endpoint = APIEndpoint.v1.apps.id(app).appStoreVersions .get( parameters: .init( filterAppVersionState: [.processingForDistribution, .readyForDistribution, .pendingAppleRelease], filterPlatform: [platform], fieldsAppStoreVersionLocalizations: [.promotionalText, .locale], limit: 1, include: [.appStoreVersionLocalizations] ) ) let response = try await provider.request(endpoint) let localizations = response.included ?. compactMap { item in if case .appStoreVersionLocalization(let localization) = item { return localization } return nil } let easyAccessLocalizations: [String: AppStoreVersionLocalization] = localizations? .reduce(into: [String: AppStoreVersionLocalization](), { $0[$1.id] = $1 }) ?? [:] let latestVersionLocalizations = response.data.first ? . relationships ?. appStoreVersionLocalizations ? . data? .reduce(into: [AppStoreLanguage: String]()) { partialResult, localization in if let matchedLocalization = easyAccessLocalizations[localization.id], let promotionalText = matchedLocalization.attributes ?. promotionalText, let language = AppStoreLanguage(rawValue: matchedLocalization.attributes ?. locale ? ? "") { partialResult[language] = promotionalText } } return latestVersionLocalizations ?? [:] }
  24. func getLatestLiveVersionPromotionalText(forApp app: String, withPlatform platform: Platform) async throws ->

    [AppStoreLanguage: String] { let platform: APIEndpoint.V1.Apps.WithID.AppStoreVersions.GetParameters.FilterPlatform = { switch platform { case .ios: return .ios case .macOs: return .macOs case .tvOs: return .tvOs case .visionOs: return .visionOs } }() let endpoint = APIEndpoint.v1.apps.id(app).appStoreVersions .get( parameters: .init( filterAppVersionState: [.processingForDistribution, .readyForDistribution, .pendingAppleRelease], filterPlatform: [platform], fieldsAppStoreVersionLocalizations: [.promotionalText, .locale], limit: 1, include: [.appStoreVersionLocalizations] ) ) let response = try await provider.request(endpoint) let localizations = response.included ?. compactMap { item in if case .appStoreVersionLocalization(let localization) = item { return localization } return nil } let easyAccessLocalizations: [String: AppStoreVersionLocalization] = localizations? .reduce(into: [String: AppStoreVersionLocalization](), { $0[$1.id] = $1 }) ?? [:] let latestVersionLocalizations = response.data.first ? . relationships ?. appStoreVersionLocalizations ? . data? .reduce(into: [AppStoreLanguage: String]()) { partialResult, localization in if let matchedLocalization = easyAccessLocalizations[localization.id], let promotionalText = matchedLocalization.attributes ?. promotionalText, let language = AppStoreLanguage(rawValue: matchedLocalization.attributes ?. locale ? ? "") { partialResult[language] = promotionalText } } return latestVersionLocalizations ?? [:] }
  25. func getLatestLiveVersionPromotionalText(forApp app: String, withPlatform platform: Platform) async throws ->

    [AppStoreLanguage: String] { let platform: APIEndpoint.V1.Apps.WithID.AppStoreVersions.GetParameters.FilterPlatform = { switch platform { case .ios: return .ios case .macOs: return .macOs case .tvOs: return .tvOs case .visionOs: return .visionOs } }() let endpoint = APIEndpoint.v1.apps.id(app).appStoreVersions .get( parameters: .init( filterAppVersionState: [.processingForDistribution, .readyForDistribution, .pendingAppleRelease], filterPlatform: [platform], fieldsAppStoreVersionLocalizations: [.promotionalText, .locale], limit: 1, include: [.appStoreVersionLocalizations] ) ) let response = try await provider.request(endpoint) let localizations = response.included ?. compactMap { item in if case .appStoreVersionLocalization(let localization) = item { return localization } return nil } let easyAccessLocalizations: [String: AppStoreVersionLocalization] = localizations? .reduce(into: [String: AppStoreVersionLocalization](), { $0[$1.id] = $1 }) ?? [:] let latestVersionLocalizations = response.data.first ? . relationships ?. appStoreVersionLocalizations ? . data? .reduce(into: [AppStoreLanguage: String]()) { partialResult, localization in if let matchedLocalization = easyAccessLocalizations[localization.id], let promotionalText = matchedLocalization.attributes ?. promotionalText, let language = AppStoreLanguage(rawValue: matchedLocalization.attributes ?. locale ? ? "") { partialResult[language] = promotionalText } } return latestVersionLocalizations ?? [:] }
  26. func getLatestLiveVersionPromotionalText(forApp app: String, withPlatform platform: Platform) async throws ->

    [AppStoreLanguage: String] { let platform: APIEndpoint.V1.Apps.WithID.AppStoreVersions.GetParameters.FilterPlatform = { switch platform { case .ios: return .ios case .macOs: return .macOs case .tvOs: return .tvOs case .visionOs: return .visionOs } }() let endpoint = APIEndpoint.v1.apps.id(app).appStoreVersions .get( parameters: .init( filterAppVersionState: [.processingForDistribution, .readyForDistribution, .pendingAppleRelease], filterPlatform: [platform], fieldsAppStoreVersionLocalizations: [.promotionalText, .locale], limit: 1, include: [.appStoreVersionLocalizations] ) ) let response = try await provider.request(endpoint) let localizations = response.included ?. compactMap { item in if case .appStoreVersionLocalization(let localization) = item { return localization } return nil } let easyAccessLocalizations: [String: AppStoreVersionLocalization] = localizations? .reduce(into: [String: AppStoreVersionLocalization](), { $0[$1.id] = $1 }) ?? [:] let latestVersionLocalizations = response.data.first ? . relationships ?. appStoreVersionLocalizations ? . data? .reduce(into: [AppStoreLanguage: String]()) { partialResult, localization in if let matchedLocalization = easyAccessLocalizations[localization.id], let promotionalText = matchedLocalization.attributes ?. promotionalText, let language = AppStoreLanguage(rawValue: matchedLocalization.attributes ?. locale ? ? "") { partialResult[language] = promotionalText } } return latestVersionLocalizations ?? [:] }
  27. func fetchAllVersionLocalizations(forVersion versionId: String) async throws - > [(String, AppStoreLanguage)]

    { let endpoint = APIEndpoint .v1 .appStoreVersions .id(versionId) .appStoreVersionLocalizations .get(parameters: .init(fieldsAppStoreVersionLocalizations: [.locale])) return try await provider.request(endpoint) .data .compactMap { localization in guard let locale = localization.attributes ?. locale, let language = AppStoreLanguage(rawValue: locale) else { return nil } return (localization.id, language) } }
  28. func update( promotionalText: String?, forLanguage language: AppStoreLanguage, ofLocalization localization: String

    ) async throws { let attributes = AppStoreVersionLocalizationUpdateRequest.Data.Attributes( promotionalText: promotionalText ) let data = AppStoreVersionLocalizationUpdateRequest.Data( type: .appStoreVersionLocalizations, id: localization, attributes: attributes ) let request = AppStoreVersionLocalizationUpdateRequest(data: data) let endpoint = APIEndpoint .v1 .appStoreVersionLocalizations .id(localization) .patch(request) _ = try await provider.request(endpoint) }
  29. func newVersion(forApp app: String, withPlatform platform: Platform, andVersion version: String)

    async throws { let lastAvailablePromotionalTexts = try await getLatestLiveVersionPromotionalText(forApp: app, withPlatform: platform) let newVersionId = try await createVersion(forApp: app, withPlatform: platform, andVersion: "1.0.0") try await withThrowingTaskGroup(of: Void.self) { taskGroup in let allVersionLocalizations = try await fetchAllVersionLocalizations(forVersion: newVersionId) for (id, language) in allVersionLocalizations { taskGroup.addTask { try await update( promotionalText: lastAvailablePromotionalTexts[language], forLanguage: language, ofLocalization: id ) } } } }
  30. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  31. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  32. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  33. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  34. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  35. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  36. func getReviews( sort: ReviewSortOrder = .dateDescending, after previousPage: URL? =

    nil ) async throws - > ReviewsResponse { let request = APIEndpoint.v1.apps.id(self.id).customerReviews .get( parameters: .init( sort: [sort.apiSort], fieldsCustomerReviews: [.body, .createdDate, .rating, .response, .reviewerNickname, .territory, .title], fieldsCustomerReviewResponses: [.review, .lastModifiedDate, .responseBody,.state], limit: 100, include: [.response] ) ) let reviews: CustomerReviewsResponse if let previousPage { let urlComponents = URLComponents(url: previousPage, resolvingAgainstBaseURL: true) let paginatedRequest = Request<CustomerReviewsResponse>( path: urlComponents ?. path ?? "", method: request.method, query: urlComponents ? . queryItems ? . map { ($0.name, $0.value) }, id: request.id ? ? "paginated_customer_reviews" ) reviews = try await provider.request(paginatedRequest) } else { reviews = try await provider.request(request) } let customerReviews = reviews.data.map { reviewData in let response = reviews.included ?. first { $0.id == reviewData.relationships ?. response ?. data ?. id } return Review(review: reviewData, response: response) } let nextPageURL: URL? = { guard let nextPageString = reviews.links.next else { return nil } return URL(string: nextPageString) }() return ReviewsResponse(reviews: customerReviews, total: reviews.meta ?. paging.total, nextPage: nextPageURL) }
  37. struct ScreenshotSet { let id: String let type: ScreenshotDisplayType }

    func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws { let imageData = try Data(contentsOf: file) }
  38. func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws {

    let imageData = try Data(contentsOf: file) // 1 let (reservationId, uploadRequests) = try await createReservation( inSet: set.id, fileName: file.lastPathComponent, imageData: imageData ) // 2 try await uploadAssets(with: uploadRequests) // 3 try await commitReservations(for: reservationId, with: imageData) }
  39. func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws {

    let imageData = try Data(contentsOf: file) // 1 let (reservationId, uploadRequests) = try await createReservation( inSet: set.id, fileName: file.lastPathComponent, imageData: imageData ) // 2 try await uploadAssets(with: uploadRequests) // 3 try await commitReservations(for: reservationId, with: imageData) }
  40. func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws {

    let imageData = try Data(contentsOf: file) // 1 let (reservationId, uploadRequests) = try await createReservation( inSet: set.id, fileName: file.lastPathComponent, imageData: imageData ) // 2 try await uploadAssets(with: uploadRequests) // 3 try await commitReservations(for: reservationId, with: imageData) }
  41. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  42. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  43. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  44. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  45. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  46. func createReservation(inSet set: String, fileName: String, imageData: Data) async throws

    -> (String, [URLRequest]) { let screenshotSetData = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet.Data(type: .appScreenshotSets, id: set) let screenshotSet = AppScreenshotCreateRequest.Data.Relationships.AppScreenshotSet(data: screenshotSetData) let relationships = AppScreenshotCreateRequest.Data.Relationships(appScreenshotSet: screenshotSet) let attributes = AppScreenshotCreateRequest.Data.Attributes(fileSize: imageData.count,fileName: fileName) let data = AppScreenshotCreateRequest.Data(type: .appScreenshots, attributes: attributes, relationships: relationships) let body = AppScreenshotCreateRequest(data: data) let reservationResponse = try await provider.request(APIEndpoint.v1.appScreenshots.post(body)) let requests = reservationResponse.data.attributes ? . uploadOperations? .compactMap { uploadOperation - > URLRequest? in guard let urlString = uploadOperation.url, let url = URL(string: urlString), let method = uploadOperation.method, let offset = uploadOperation.offset, let length = uploadOperation.length else { return nil } let chunk = imageData[offset ..< (offset + length)] var request = URLRequest(url: url) request.httpMethod = method for header in (uploadOperation.requestHeaders ?? []) { if let name = header.name { request.setValue(header.value, forHTTPHeaderField: name) } } request.httpBody = chunk return request } ?? [] return (reservationResponse.data.id, requests) }
  47. func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws {

    let imageData = try Data(contentsOf: file) // 1 let (reservationId, uploadRequests) = try await createReservation( inSet: set.id, fileName: file.lastPathComponent, imageData: imageData ) // 2 try await uploadAssets(with: uploadRequests) // 3 try await commitReservations(for: reservationId, with: imageData) }
  48. func uploadAssets(with requests: [URLRequest]) async throws { let session =

    URLSession.shared _ = try await withThrowingTaskGroup(of: Data.self, returning: [Data].self) { taskGroup in for request in requests { taskGroup.addTask { let (data, _) = try await session.data(for: request) return data } } var responses = [Data]() for try await result in taskGroup { responses.append(result) } return responses } }
  49. func uploadAssets(with requests: [URLRequest]) async throws { let session =

    URLSession.shared _ = try await withThrowingTaskGroup(of: Data.self, returning: [Data].self) { taskGroup in for request in requests { taskGroup.addTask { let (data, _) = try await session.data(for: request) return data } } var responses = [Data]() for try await result in taskGroup { responses.append(result) } return responses } }
  50. func uploadAssets(with requests: [URLRequest]) async throws { let session =

    URLSession.shared _ = try await withThrowingTaskGroup(of: Data.self, returning: [Data].self) { taskGroup in for request in requests { taskGroup.addTask { let (data, _) = try await session.data(for: request) return data } } var responses = [Data]() for try await result in taskGroup { responses.append(result) } return responses } }
  51. func uploadScreenshot(_ file: URL, to set: ScreenshotSet) async throws {

    let imageData = try Data(contentsOf: file) // 1 let (reservationId, uploadRequests) = try await createReservation( inSet: set.id, fileName: file.lastPathComponent, imageData: imageData ) // 2 try await uploadAssets(with: uploadRequests) // 3 try await commitReservations(for: reservationId, with: imageData) }
  52. import Crypto func commitReservations(for reservation: String, with imageData: Data) async

    throws { let bytes = Array(Crypto.Insecure.MD5.hash(data: imageData).makeIterator()) let checksum = bytes.map { String(format: "%02x", $0) }.joined() let screenshotUpdateRequestAttributes = AppScreenshotUpdateRequest.Data.Attributes( sourceFileChecksum: checksum, isUploaded: true ) let screenshotUpdateRequestData = AppScreenshotUpdateRequest.Data( type: .appScreenshots, id: reservation, attributes: screenshotUpdateRequestAttributes ) let screenshotUpdateRequest = AppScreenshotUpdateRequest(data: screenshotUpdateRequestData) let reservationCommitment = APIEndpoint .v1 .appScreenshots .id(reservation) .patch(screenshotUpdateRequest) _ = try await provider.request(reservationCommitment) }
  53. import Crypto func commitReservations(for reservation: String, with imageData: Data) async

    throws { let bytes = Array(Crypto.Insecure.MD5.hash(data: imageData).makeIterator()) let checksum = bytes.map { String(format: "%02x", $0) }.joined() let screenshotUpdateRequestAttributes = AppScreenshotUpdateRequest.Data.Attributes( sourceFileChecksum: checksum, isUploaded: true ) let screenshotUpdateRequestData = AppScreenshotUpdateRequest.Data( type: .appScreenshots, id: reservation, attributes: screenshotUpdateRequestAttributes ) let screenshotUpdateRequest = AppScreenshotUpdateRequest(data: screenshotUpdateRequestData) let reservationCommitment = APIEndpoint .v1 .appScreenshots .id(reservation) .patch(screenshotUpdateRequest) _ = try await provider.request(reservationCommitment) }
  54. import Crypto func commitReservations(for reservation: String, with imageData: Data) async

    throws { let bytes = Array(Crypto.Insecure.MD5.hash(data: imageData).makeIterator()) let checksum = bytes.map { String(format: "%02x", $0) }.joined() let screenshotUpdateRequestAttributes = AppScreenshotUpdateRequest.Data.Attributes( sourceFileChecksum: checksum, isUploaded: true ) let screenshotUpdateRequestData = AppScreenshotUpdateRequest.Data( type: .appScreenshots, id: reservation, attributes: screenshotUpdateRequestAttributes ) let screenshotUpdateRequest = AppScreenshotUpdateRequest(data: screenshotUpdateRequestData) let reservationCommitment = APIEndpoint .v1 .appScreenshots .id(reservation) .patch(screenshotUpdateRequest) _ = try await provider.request(reservationCommitment) }
  55. extension NSImage { func removeAlphaChannel() -> NSImage? { guard let

    cgImage = self.cgImage(forProposedRect: nil, context: nil, hints: nil), let colorSpace = cgImage.colorSpace, let context = CGContext( data: nil, width: cgImage.width, height: cgImage.height, bitsPerComponent: cgImage.bitsPerComponent, bytesPerRow: cgImage.bytesPerRow, space: colorSpace, bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue ) else { return nil } context.draw(cgImage, in: CGRect(x: 0, y: 0, width: context.width, height: context.height)) guard let newCg = context.makeImage() else { return nil } return NSImage(cgImage: newCg, size: .zero) } }
  56. Ship Easy Ship Fast Automate manual processes App Store Connect

    API Build your own automations Customer Reviews Multiple locales Handle content for multiple platforms Automatically upload assets in bulk