From 9e0d8a6c0e227e7787ee1b40d4b9506f69523e2d Mon Sep 17 00:00:00 2001 From: Alexandr Romanov Date: Wed, 25 Dec 2024 22:52:13 +0300 Subject: [PATCH] Update --- .../OversizeAppStoreServices/Models/App.swift | 33 ++- .../Models/AppStoreVersion.swift | 59 ++++-- .../Models/Typs/AppStoreLanguage.swift | 44 ++++ .../Models/XcodeMetrics.swift | 195 ++++++++++++++++++ .../ServiceRegistering.swift | 4 + .../Services/AppsService.swift | 5 +- .../Services/PerfPowerMetricsService.swift | 49 +++++ .../Services/VersionsService.swift | 108 +++++++--- 8 files changed, 448 insertions(+), 49 deletions(-) create mode 100644 Sources/OversizeAppStoreServices/Models/XcodeMetrics.swift create mode 100644 Sources/OversizeAppStoreServices/Services/PerfPowerMetricsService.swift diff --git a/Sources/OversizeAppStoreServices/Models/App.swift b/Sources/OversizeAppStoreServices/Models/App.swift index 9a999b4..a552e60 100644 --- a/Sources/OversizeAppStoreServices/Models/App.swift +++ b/Sources/OversizeAppStoreServices/Models/App.swift @@ -91,8 +91,7 @@ public struct App: Identifiable, Sendable { self.included = Included( appStoreVersions: appStoreVersions.compactMap { appStoreVersion in .init( - schema: appStoreVersion, - builds: [] + schema: appStoreVersion ) }, builds: builds.compactMap { .init(schema: $0) }.sorted(by: { $0.uploadedDate > $1.uploadedDate }), @@ -146,8 +145,7 @@ public struct App: Identifiable, Sendable { self.included = Included( appStoreVersions: appStoreVersions.compactMap { appStoreVersion in .init( - schema: appStoreVersion, - builds: [] + schema: appStoreVersion ) }, builds: builds.compactMap { .init(schema: $0) }.sorted(by: { $0.uploadedDate > $1.uploadedDate }), @@ -184,5 +182,32 @@ public extension App { public var visionOsAppStoreVersions: [AppStoreVersion] { appStoreVersions.filter { $0.platform == .visionOs } } + + public var appStoreVersionsPlatforms: [Platform] { + var platforms: [Platform] = [] + + if !macOsAppStoreVersions.isEmpty { + platforms.append(.macOs) + } + if !iOsAppStoreVersions.isEmpty { + platforms.append(.ios) + } + if !tvOsAppStoreVersions.isEmpty { + platforms.append(.tvOs) + } + if !visionOsAppStoreVersions.isEmpty { + platforms.append(.visionOs) + } + + return platforms + } + } + + var firstNamePath: String { + name.components(separatedBy: [".", "-", "—"]).first ?? "" + } + + var lastNamePath: String { + name.components(separatedBy: [".", "-", "—"]).last ?? "" } } diff --git a/Sources/OversizeAppStoreServices/Models/AppStoreVersion.swift b/Sources/OversizeAppStoreServices/Models/AppStoreVersion.swift index 56275b5..033cba5 100644 --- a/Sources/OversizeAppStoreServices/Models/AppStoreVersion.swift +++ b/Sources/OversizeAppStoreServices/Models/AppStoreVersion.swift @@ -22,8 +22,9 @@ public struct AppStoreVersion: Sendable, Identifiable { public let createdDate: Date? public let included: Included? + public let relationships: Relationships? - init?(schema: AppStoreAPI.AppStoreVersion, builds: [AppStoreAPI.Build] = []) { + init?(schema: AppStoreAPI.AppStoreVersion, included: [AppStoreAPI.AppStoreVersionsResponse.IncludedItem]? = nil) { guard let storeState = schema.attributes?.appStoreState?.rawValue, let storeStateType: AppStoreVersionState = .init(rawValue: storeState), let state = schema.attributes?.appVersionState?.rawValue, @@ -44,22 +45,56 @@ public struct AppStoreVersion: Sendable, Identifiable { reviewType = .init(rawValue: schema.attributes?.createdDate?.rawValue ?? "") releaseType = .init(rawValue: schema.attributes?.releaseType?.rawValue ?? "") - if let build = builds.first(where: { $0.id == schema.relationships?.build?.data?.id ?? "" }) { - included = .init( - builds: builds.compactMap { .init(schema: $0) }.sorted(by: { $0.uploadedDate > $1.uploadedDate }), - build: .init(schema: build) - ) + var includedApp: AppStoreAPI.App? + var includedAgeRatingDeclaration: AppStoreAPI.AgeRatingDeclaration? + var includedAppStoreVersionLocalizations: [AppStoreAPI.AppStoreVersionLocalization]? + var includedBuild: AppStoreAPI.Build? + var includedAppStoreReviewDetail: [AppStoreAPI.AppStoreReviewDetail]? - } else { - included = .init( - builds: builds.compactMap { .init(schema: $0) }.sorted(by: { $0.uploadedDate > $1.uploadedDate }), - build: nil - ) + if let includedItems = included { + for includedItem in includedItems { + switch includedItem { + case let .app(app): + includedApp = app + case let .ageRatingDeclaration(ageRatingDeclaration): + includedAgeRatingDeclaration = ageRatingDeclaration + case let .appStoreVersionLocalization(appStoreVersionLocalization): + includedAppStoreVersionLocalizations?.append(appStoreVersionLocalization) + case let .build(build): + includedBuild = build + case let .appStoreReviewDetail(appStoreReviewDetail): + includedAppStoreReviewDetail?.append(appStoreReviewDetail) + default: continue + } + } } + + self.included = Included( + app: includedApp.flatMap { .init(schema: $0) }, + build: includedBuild.flatMap { .init(schema: $0) }, + ageRatingDeclaration: includedAgeRatingDeclaration.flatMap { .init(schema: $0) }, + appStoreVersionLocalizations: includedAppStoreVersionLocalizations.flatMap { localizations in + localizations.flatMap(AppStoreVersionLocalization.init) + }, + appStoreReviewDetail: includedAppStoreReviewDetail.flatMap { reviewDetails in + reviewDetails.flatMap(AppStoreReviewDetail.init) + } + ) + + relationships = .init( + appStoreVersionLocalizationsIds: schema.relationships?.appStoreVersionLocalizations?.data?.compactMap { $0.id } + ) } public struct Included: Sendable { - public let builds: [Build] + public let app: App? public let build: Build? + public let ageRatingDeclaration: AgeRatingDeclaration? + public let appStoreVersionLocalizations: [AppStoreVersionLocalization]? + public let appStoreReviewDetail: [AppStoreReviewDetail]? + } + + public struct Relationships: Sendable { + public let appStoreVersionLocalizationsIds: [String]? } } diff --git a/Sources/OversizeAppStoreServices/Models/Typs/AppStoreLanguage.swift b/Sources/OversizeAppStoreServices/Models/Typs/AppStoreLanguage.swift index a6fc231..983cfd8 100644 --- a/Sources/OversizeAppStoreServices/Models/Typs/AppStoreLanguage.swift +++ b/Sources/OversizeAppStoreServices/Models/Typs/AppStoreLanguage.swift @@ -93,4 +93,48 @@ public enum AppStoreLanguage: String, CaseIterable, Codable, Sendable, Identifia case .vietnamese: "Vietnamese" } } + + public var flagEmoji: String { + switch self { + case .arabic: "🇸🇦" + case .catalan: "🇪🇸" + case .chineseSimplified, .chineseTraditional: "🇨🇳" + case .croatian: "🇭🇷" + case .czech: "🇨🇿" + case .danish: "🇩🇰" + case .dutch: "🇳🇱" + case .englishUS: "🇺🇸" + case .englishAUS: "🇦🇺" + case .englishCAN: "🇨🇦" + case .englishUK: "🇬🇧" + case .finnish: "🇫🇮" + case .french: "🇫🇷" + case .frenchCAN: "🇨🇦" + case .german: "🇩🇪" + case .greek: "🇬🇷" + case .hebrew: "🇮🇱" + case .hindi: "🇮🇳" + case .hungarian: "🇭🇺" + case .indonesian: "🇮🇩" + case .italian: "🇮🇹" + case .japanese: "🇯🇵" + case .korean: "🇰🇷" + case .malay: "🇲🇾" + case .macedonian: "🇲🇰" + case .norwegian: "🇳🇴" + case .polish: "🇵🇱" + case .portuguese: "🇵🇹" + case .portugueseBRA: "🇧🇷" + case .romanian: "🇷🇴" + case .russian: "🇷🇺" + case .slovak: "🇸🇰" + case .spanish: "🇪🇸" + case .spanishMEX: "🇲🇽" + case .swedish: "🇸🇪" + case .thai: "🇹🇭" + case .turkish: "🇹🇷" + case .ukrainian: "🇺🇦" + case .vietnamese: "🇻🇳" + } + } } diff --git a/Sources/OversizeAppStoreServices/Models/XcodeMetrics.swift b/Sources/OversizeAppStoreServices/Models/XcodeMetrics.swift new file mode 100644 index 0000000..dcbcd9a --- /dev/null +++ b/Sources/OversizeAppStoreServices/Models/XcodeMetrics.swift @@ -0,0 +1,195 @@ +import AppStoreAPI +import AppStoreConnect +import Foundation + +public struct LocalXcodeMetrics: Codable, Equatable, Sendable { + public let version: String? + public let insights: LocalInsights? + public let productData: [LocalProductData]? + + public init(dto: XcodeMetrics) { + version = dto.version + insights = dto.insights.map { LocalInsights(dto: $0) } + productData = dto.productData?.map { LocalProductData(dto: $0) } + } +} + +// MARK: - Insights + +public struct LocalInsights: Codable, Equatable, Sendable { + public let trendingUp: [LocalMetricsInsight]? + public let regressions: [LocalMetricsInsight]? + + public init(dto: XcodeMetrics.Insights) { + trendingUp = dto.trendingUp?.map { LocalMetricsInsight(dto: $0) } + regressions = dto.regressions?.map { LocalMetricsInsight(dto: $0) } + } +} + +// MARK: - Product Data + +public struct LocalProductData: Codable, Equatable, Sendable { + public let platform: String? + public let metricCategories: [LocalMetricCategory]? + + public init(dto: XcodeMetrics.ProductDatum) { + platform = dto.platform + metricCategories = dto.metricCategories?.map { LocalMetricCategory(dto: $0) } + } +} + +// MARK: - Metric Category + +public struct LocalMetricCategory: Codable, Equatable, Sendable { + public let identifier: String? + public let metrics: [LocalMetric]? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory) { + identifier = dto.identifier?.rawValue + metrics = dto.metrics?.map { LocalMetric(dto: $0) } + } +} + +// MARK: - Metric + +public struct LocalMetric: Codable, Equatable, Sendable { + public let identifier: String? + public let goalKeys: [LocalGoalKey]? + public let unit: LocalUnit? + public let datasets: [LocalDataset]? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric) { + identifier = dto.identifier + goalKeys = dto.goalKeys?.map { LocalGoalKey(dto: $0) } + unit = dto.unit.map { LocalUnit(dto: $0) } + datasets = dto.datasets?.map { LocalDataset(dto: $0) } + } +} + +// MARK: - Goal Key + +public struct LocalGoalKey: Codable, Equatable, Sendable { + public let goalKey: String? + public let lowerBound: Int? + public let upperBound: Int? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.GoalKey) { + goalKey = dto.goalKey + lowerBound = dto.lowerBound + upperBound = dto.upperBound + } +} + +// MARK: - Unit + +public struct LocalUnit: Codable, Equatable, Sendable { + public let identifier: String? + public let displayName: String? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.Unit) { + identifier = dto.identifier + displayName = dto.displayName + } +} + +// MARK: - Dataset + +public struct LocalDataset: Codable, Equatable, Sendable { + public let filterCriteria: LocalFilterCriteria? + public let points: [LocalPoint]? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.Dataset) { + filterCriteria = dto.filterCriteria.map { LocalFilterCriteria(dto: $0) } + points = dto.points?.map { LocalPoint(dto: $0) } + } +} + +// MARK: - Filter Criteria + +public struct LocalFilterCriteria: Codable, Equatable, Sendable { + public let percentile: String? + public let device: String? + public let deviceMarketingName: String? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.Dataset.FilterCriteria) { + percentile = dto.percentile + device = dto.device + deviceMarketingName = dto.deviceMarketingName + } +} + +// MARK: - Point + +public struct LocalPoint: Codable, Equatable, Sendable { + public let version: String? + public let value: Double? + public let errorMargin: Double? + public let percentageBreakdown: LocalPercentageBreakdown? + public let goal: String? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.Dataset.Point) { + version = dto.version + value = dto.value + errorMargin = dto.errorMargin + percentageBreakdown = dto.percentageBreakdown.map { LocalPercentageBreakdown(dto: $0) } + goal = dto.goal + } +} + +// MARK: - Percentage Breakdown + +public struct LocalPercentageBreakdown: Codable, Equatable, Sendable { + public let value: Double? + public let subSystemLabel: String? + + public init(dto: XcodeMetrics.ProductDatum.MetricCategory.Metric.Dataset.Point.PercentageBreakdown) { + value = dto.value + subSystemLabel = dto.subSystemLabel + } +} + +// MARK: - Metrics Insight + +public struct LocalMetricsInsight: Codable, Equatable, Sendable { + public let metricCategory: String? + public let latestVersion: String? + public let metric: String? + public let summaryString: String? + public let referenceVersions: String? + public let maxLatestVersionValue: Double? + public let subSystemLabel: String? + public let isHighImpact: Bool? + public let populations: [LocalPopulation]? + + public init(dto: MetricsInsight) { + metricCategory = dto.metricCategory?.rawValue + latestVersion = dto.latestVersion + metric = dto.metric + summaryString = dto.summaryString + referenceVersions = dto.referenceVersions + maxLatestVersionValue = dto.maxLatestVersionValue + subSystemLabel = dto.subSystemLabel + isHighImpact = dto.isHighImpact + populations = dto.populations?.map { LocalPopulation(dto: $0) } + } +} + +// MARK: - Population + +public struct LocalPopulation: Codable, Equatable, Sendable { + public let deltaPercentage: Double? + public let percentile: String? + public let summaryString: String? + public let referenceAverageValue: Double? + public let latestVersionValue: Double? + public let device: String? + + public init(dto: MetricsInsight.Population) { + deltaPercentage = dto.deltaPercentage + percentile = dto.percentile + summaryString = dto.summaryString + referenceAverageValue = dto.referenceAverageValue + latestVersionValue = dto.latestVersionValue + device = dto.device + } +} diff --git a/Sources/OversizeAppStoreServices/ServiceRegistering.swift b/Sources/OversizeAppStoreServices/ServiceRegistering.swift index 80db32a..64264cd 100644 --- a/Sources/OversizeAppStoreServices/ServiceRegistering.swift +++ b/Sources/OversizeAppStoreServices/ServiceRegistering.swift @@ -61,4 +61,8 @@ public extension Container { var cacheService: Factory { self { CacheService() } } + + var perfPowerMetricsService: Factory { + self { PerfPowerMetricsService() } + } } diff --git a/Sources/OversizeAppStoreServices/Services/AppsService.swift b/Sources/OversizeAppStoreServices/Services/AppsService.swift index 4cb7a61..4a5f59f 100644 --- a/Sources/OversizeAppStoreServices/Services/AppsService.swift +++ b/Sources/OversizeAppStoreServices/Services/AppsService.swift @@ -117,7 +117,7 @@ public actor AppsService { } } - public func fetchAppsIncludeActualAppStoreVersionsAndBuilds() async -> Result<[App], AppError> { + public func fetchAppsIncludeActualAppStoreVersionsAndBuilds(limitAppStoreVersions: Int? = nil) async -> Result<[App], AppError> { guard let client else { return .failure(.network(type: .unauthorized)) } let request = Resources.v1.apps.get( filterAppStoreVersionsAppStoreState: [ @@ -144,7 +144,8 @@ public actor AppsService { include: [ .builds, .appStoreVersions, - ] + ], + limitAppStoreVersions: limitAppStoreVersions ) do { let result = try await client.send(request) diff --git a/Sources/OversizeAppStoreServices/Services/PerfPowerMetricsService.swift b/Sources/OversizeAppStoreServices/Services/PerfPowerMetricsService.swift new file mode 100644 index 0000000..d8dc250 --- /dev/null +++ b/Sources/OversizeAppStoreServices/Services/PerfPowerMetricsService.swift @@ -0,0 +1,49 @@ +// +// Copyright © 2024 Alexander Romanov +// PerfPowerMetricsService.swift, created on 16.12.2024 +// + +import AppStoreAPI +import AppStoreConnect +import OversizeModels + +public actor PerfPowerMetricsService { + private let client: AppStoreConnectClient? + + public init() { + do { + client = try AppStoreConnectClient(authenticator: EnvAuthenticator()) + } catch { + client = nil + } + } + + public func fetchXcodeMetrics(appId: String) async -> Result { + guard let client else { return .failure(.network(type: .unauthorized)) } + let request = Resources.v1.apps.id(appId).perfPowerMetrics.get() + do { + let data = try await client.send(request) + print(data) + let localXcodeMetrics: LocalXcodeMetrics = .init(dto: data) + + return .success(localXcodeMetrics) + + } catch { + return .failure(.network(type: .noResponse)) + } + } + + public func fetchBuildMetrics(buildId: String) async -> Result { + guard let client else { return .failure(.network(type: .unauthorized)) } + let request = Resources.v1.builds.id(buildId).perfPowerMetrics.get() + + do { + let data = try await client.send(request) + let localXcodeMetrics: LocalXcodeMetrics = .init(dto: data) + return .success(localXcodeMetrics) + + } catch { + return .failure(.network(type: .noResponse)) + } + } +} diff --git a/Sources/OversizeAppStoreServices/Services/VersionsService.swift b/Sources/OversizeAppStoreServices/Services/VersionsService.swift index c7cc474..576f2e2 100644 --- a/Sources/OversizeAppStoreServices/Services/VersionsService.swift +++ b/Sources/OversizeAppStoreServices/Services/VersionsService.swift @@ -46,10 +46,16 @@ public actor VersionsService { } } - public func fetchEditableAppStoreVersion(appId: String, force: Bool = false) async -> Result<[AppStoreVersion], AppError> { + public func fetchEditableAppStoreVersion(appId: String, platform: Platform? = nil, force: Bool = false) async -> Result<[AppStoreVersion], AppError> { guard let client else { return .failure(.network(type: .unauthorized)) } - return await cacheService.fetchWithCache(key: "fetchEditableAppStoreVersion\(appId)", force: force) { + let filterPlatforms: [Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform]? = if let platform, let filteredPlatform: Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform = .init(rawValue: platform.rawValue) { + [filteredPlatform] + } else { + nil + } + return await cacheService.fetchWithCache(key: "fetchEditableAppStoreVersion\(appId)\(platform?.rawValue ?? "")", force: force) { let request = Resources.v1.apps.id(appId).appStoreVersions.get( + filterPlatform: filterPlatforms, filterAppVersionState: [ .prepareForSubmission, .metadataRejected, @@ -81,36 +87,76 @@ public actor VersionsService { } } - public func fetchActualAppStoreVersions(appId: String) async -> Result<[AppStoreVersion], AppError> { + public func fetchActualAppStoreVersions(appId: String, platform: Platform? = nil, force: Bool = false) async -> Result<[AppStoreVersion], AppError> { guard let client else { return .failure(.network(type: .unauthorized)) } - let request = Resources.v1.apps.id(appId).appStoreVersions.get( - filterAppStoreState: [ - .accepted, - .developerRemovedFromSale, - .developerRejected, - .inReview, - .invalidBinary, - .metadataRejected, - .pendingAppleRelease, - .pendingContract, - .pendingDeveloperRelease, - .prepareForSubmission, - .preorderReadyForSale, - .processingForAppStore, - .readyForReview, - .readyForSale, - .rejected, - .removedFromSale, - .waitingForExportCompliance, - .waitingForReview, - .notApplicable, - ] - ) - do { - let data = try await client.send(request).data - return .success(data.compactMap { .init(schema: $0) }) - } catch { - return .failure(.network(type: .noResponse)) + let filterPlatforms: [Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform]? = if let platform, let filteredPlatform: Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform = .init(rawValue: platform.rawValue) { + [filteredPlatform] + } else { + nil + } + return await cacheService.fetchWithCache(key: "fetchActualAppStoreVersions\(appId)\(platform?.rawValue ?? "")", force: force) { + let request = Resources.v1.apps.id(appId).appStoreVersions.get( + filterPlatform: filterPlatforms, + filterAppVersionState: [ + .accepted, + .developerRejected, + .inReview, + .invalidBinary, + .metadataRejected, + .pendingAppleRelease, + .pendingDeveloperRelease, + .prepareForSubmission, + .processingForDistribution, + .readyForDistribution, + .readyForReview, + .rejected, + .waitingForExportCompliance, + .waitingForReview, + ] + ) + let response = try await client.send(request) + return response.data + }.map { data in + data.compactMap { .init(schema: $0) } + } + } + + public func fetchActualAppStoreVersionsIncludeBuilds(appId: String, platform: Platform? = nil, limit: Int? = nil, force: Bool = false) async -> Result<[AppStoreVersion], AppError> { + guard let client else { return .failure(.network(type: .unauthorized)) } + let filterPlatforms: [Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform]? = if let platform, let filteredPlatform: Resources.V1.Apps.WithID.AppStoreVersions.FilterPlatform = .init(rawValue: platform.rawValue) { + [filteredPlatform] + } else { + nil + } + return await cacheService.fetchWithCache(key: "fetchActualAppStoreVersionsIncludeBuilds\(appId)\(platform?.rawValue ?? "")", force: force) { + let request = Resources.v1.apps.id(appId).appStoreVersions.get( + filterPlatform: filterPlatforms, + filterAppVersionState: [ + .accepted, + .developerRejected, + .inReview, + .invalidBinary, + .metadataRejected, + .pendingAppleRelease, + .pendingDeveloperRelease, + .prepareForSubmission, + .processingForDistribution, + .readyForDistribution, + .readyForReview, + .rejected, + .waitingForExportCompliance, + .waitingForReview, + ], + limit: limit, + include: [ + .build, + .appStoreVersionLocalizations, + ] + ) + + return try await client.send(request) + }.map { data in + data.data.compactMap { .init(schema: $0, included: data.included) } } }