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

Avatar for Pol Piella Abadia

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