Skip to content

Commit

Permalink
Add CacheService
Browse files Browse the repository at this point in the history
  • Loading branch information
aromanov91 committed Dec 11, 2024
1 parent 1fa00e8 commit 343fec6
Show file tree
Hide file tree
Showing 8 changed files with 282 additions and 116 deletions.
55 changes: 39 additions & 16 deletions Sources/OversizeAppStoreServices/Cache/СacheService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@

import Foundation
import OversizeCore
import OversizeModels

public final class СacheService {
public actor CacheService {
private let cacheDirectory: URL
private let cacheExpiration: TimeInterval

Expand All @@ -20,9 +21,8 @@ public final class СacheService {
cacheDirectory.appendingPathComponent(key)
}

func save(_ data: some Encodable, key: String = #function) {
let trimmedKey = key.hasSuffix("()") ? String(key.dropLast(2)) : key
let fileURL = cacheFilePath(for: trimmedKey)
public func save(_ data: some Encodable, key: String) async {
let fileURL = cacheFilePath(for: key)
do {
let jsonData = try JSONEncoder().encode(data)
try jsonData.write(to: fileURL, options: .atomic)
Expand All @@ -32,37 +32,32 @@ public final class СacheService {
}
}

func load<T: Decodable>(key: String = #function, as _: T.Type) -> T? {
let trimmedKey = key.hasSuffix("()") ? String(key.dropLast(2)) : key
let fileURL = cacheFilePath(for: trimmedKey)

guard FileManager.default.fileExists(atPath: fileURL.path),
isCacheValid(for: fileURL)
else {
public func load<T: Decodable>(key: String, as _: T.Type) async -> T? {
let fileURL = cacheFilePath(for: key)
guard FileManager.default.fileExists(atPath: fileURL.path), await isCacheValid(for: fileURL) else {
return nil
}

do {
let data = try Data(contentsOf: fileURL)
logNotice("Readed cache: \(key)")
logNotice("Read cache: \(key)")
return try JSONDecoder().decode(T.self, from: data)
} catch {
logError("Failed to load cache for key \(key): \(error)")
return nil
}
}

func remove(for key: String = #function) {
let trimmedKey = key.hasSuffix("()") ? String(key.dropLast(2)) : key
public func remove(for key: String) async {
let fileURL = cacheFilePath(for: key)
try? FileManager.default.removeItem(at: fileURL)
}

func clearAll() {
public func clearAll() async {
try? FileManager.default.removeItem(at: cacheDirectory)
}

private func isCacheValid(for fileURL: URL) -> Bool {
private func isCacheValid(for fileURL: URL) async -> Bool {
guard let attributes = try? FileManager.default.attributesOfItem(atPath: fileURL.path),
let modificationDate = attributes[.modificationDate] as? Date
else {
Expand All @@ -71,3 +66,31 @@ public final class СacheService {
return Date().timeIntervalSince(modificationDate) < cacheExpiration
}
}

public extension CacheService {
func fetchWithCache<T: Codable & Sendable>(
key: String,
force: Bool = false,
fetcher: () async throws -> T
) async -> Result<T, AppError> {
if !force {
if let cachedData: T = await load(key: key, as: T.self) {
logNotice("Returning cached data for key: \(key)")
return .success(cachedData)
}
}

do {
logNetwork(force ? "Force fetching: \(key)" : "Fetching: \(key)")
let fetchedData = try await fetcher()
await save(fetchedData, key: key) // Save new data to cache
return .success(fetchedData)
} catch let error as AppError {
logError("Failed to fetch data for key \(key): \(error)")
return .failure(error)
} catch {
logError("Unexpected error during fetch for key \(key): \(error)")
return .failure(.network(type: .noResponse))
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import AppStoreConnect
import Foundation
import OversizeCore

public struct VersionLocalization: Identifiable, Hashable, Sendable {
public struct AppStoreVersionLocalization: Identifiable, Hashable, Sendable {
public let id: String
public let locale: AppStoreLanguage
public let description: String?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import Foundation

public enum AppStoreLanguage: String, CaseIterable, Codable, Sendable {
public enum AppStoreLanguage: String, CaseIterable, Codable, Sendable, Identifiable {
public var id: String { rawValue }

case arabic = "ar-SA"
case catalan = "ca"
case chineseSimplified = "zh-Hans"
Expand Down
4 changes: 2 additions & 2 deletions Sources/OversizeAppStoreServices/ServiceRegistering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public extension Container {
self { SalesAndFinanceService() }
}

var cacheService: Factory<СacheService> {
self { СacheService() }
var cacheService: Factory<CacheService> {
self { CacheService() }
}
}
80 changes: 61 additions & 19 deletions Sources/OversizeAppStoreServices/Services/AppInfoService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@

import AppStoreAPI
import AppStoreConnect
import Factory
import Foundation
import OversizeCore
import OversizeModels

public actor AppInfoService {
@Injected(\.cacheService) private var cacheService: CacheService
private let client: AppStoreConnectClient?

public init() {
Expand All @@ -20,14 +22,13 @@ public actor AppInfoService {
}
}

public func fetchAppInfos(appId: String) async -> Result<[AppInfo], AppError> {
public func fetchAppInfos(appId: String, force: Bool = false) async -> Result<[AppInfo], AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }
let request = Resources.v1.apps.id(appId).appInfos.get()
do {
let data = try await client.send(request).data
return .success(data.compactMap { .init(schema: $0) })
} catch {
return .failure(.network(type: .noResponse))
return await cacheService.fetchWithCache(key: "fetchAppInfos\(appId)", force: force) {
let request = Resources.v1.apps.id(appId).appInfos.get()
return try await client.send(request).data
}.map { data in
data.compactMap { .init(schema: $0) }
}
}

Expand All @@ -36,24 +37,21 @@ public actor AppInfoService {
let request = Resources.v1.apps.id(appId).appInfos.get(include: [.primaryCategory, .secondaryCategory, .ageRatingDeclaration])
do {
let responce = try await client.send(request)
return .success(
responce.data
.compactMap {
.init(schema: $0, included: responce.included)
})
return .success(responce.data.compactMap {
.init(schema: $0, included: responce.included)
})
} catch {
return .failure(.network(type: .noResponse))
}
}

public func fetchAppInfoLocalizations(appInfoId: String) async -> Result<[AppInfoLocalization], AppError> {
public func fetchAppInfoLocalizations(appInfoId: String, force: Bool = false) async -> Result<[AppInfoLocalization], AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }
let request = Resources.v1.appInfos.id(appInfoId).appInfoLocalizations.get()
do {
let data = try await client.send(request).data
return .success(data.compactMap { .init(schema: $0) })
} catch {
return .failure(.network(type: .noResponse))
return await cacheService.fetchWithCache(key: "fetchAppInfoLocalizations\(appInfoId)", force: force) {
let request = Resources.v1.appInfos.id(appInfoId).appInfoLocalizations.get()
return try await client.send(request).data
}.map { data in
data.compactMap { .init(schema: $0) }
}
}

Expand Down Expand Up @@ -180,4 +178,48 @@ public actor AppInfoService {
return .failure(.network(type: .noResponse))
}
}

public func postAppInfoLocalization(
appInfoId: String,
language: AppStoreLanguage,
name: String,
subtitle: String? = nil,
privacyPolicyURL: String? = nil,
privacyChoicesURL: String? = nil,
privacyPolicyText: String? = nil
) async -> Result<AppInfoLocalization, AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }

let requestAttributes: AppInfoLocalizationCreateRequest.Data.Attributes = .init(
locale: language.rawValue,
name: name,
subtitle: subtitle,
privacyPolicyURL: privacyPolicyURL,
privacyChoicesURL: privacyChoicesURL,
privacyPolicyText: privacyPolicyText
)

let requestData: AppInfoLocalizationCreateRequest.Data = .init(
type: .appInfoLocalizations,
attributes: requestAttributes,
relationships: .init(
appInfo: .init(data: .init(
type: .appInfos,
id: appInfoId
)
)
)
)
let request = Resources.v1.appInfoLocalizations.post(.init(data: requestData))

do {
let data = try await client.send(request).data
guard let versionLocalization: AppInfoLocalization = .init(schema: data) else {
return .failure(.network(type: .decode))
}
return .success(versionLocalization)
} catch {
return .failure(.network(type: .noResponse))
}
}
}
93 changes: 67 additions & 26 deletions Sources/OversizeAppStoreServices/Services/AppsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import OversizeCore
import OversizeModels

public actor AppsService {
@Injected(\.cacheService) private var cacheService: СacheService
@Injected(\.cacheService) private var cacheService: CacheService
private let client: AppStoreConnectClient?

public init() {
Expand All @@ -22,17 +22,17 @@ public actor AppsService {
}
}

public func fetchApp(id: String) async -> Result<App, AppError> {
public func fetchApp(id: String, force: Bool = false) async -> Result<App, AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }
let request = Resources.v1.apps.id(id).get()
do {
let data = try await client.send(request).data
guard let app: App = .init(schema: data) else {
return await cacheService.fetchWithCache(key: "fetchApp\(id)", force: force) {
let request = Resources.v1.apps.id(id).get()
let response = try await client.send(request)
return response.data
}.flatMap { data in
guard let app = App(schema: data) else {
return .failure(.network(type: .decode))
}
return .success(app)
} catch {
return .failure(.network(type: .noResponse))
}
}

Expand Down Expand Up @@ -79,25 +79,19 @@ public actor AppsService {
}
}

public func fetchAppsIncludeAppStoreVersionsAndBuildsAndPreReleaseVersions() async -> Result<[App], AppError> {
if let cachedData: AppsResponse = cacheService.load(as: AppsResponse.self) {
return .success(processAppsResponse(cachedData))
}
public func fetchAppsIncludeAppStoreVersionsAndBuildsAndPreReleaseVersions(forse: Bool = false) async -> Result<[App], AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }

let request = Resources.v1.apps.get(
include: [
.builds,
.appStoreVersions,
.preReleaseVersions,
]
)
do {
let response = try await client.send(request)
cacheService.save(response)
return .success(processAppsResponse(response))
} catch {
return .failure(.network(type: .noResponse))
return await cacheService.fetchWithCache(key: "fetchAppsIncludeAppStoreVersionsAndBuildsAndPreReleaseVersions", force: forse) {
let request = Resources.v1.apps.get(
include: [
.builds,
.appStoreVersions,
.preReleaseVersions,
]
)
return try await client.send(request)
}.flatMap {
.success(processAppsResponse($0))
}
}

Expand Down Expand Up @@ -199,6 +193,30 @@ public actor AppsService {
return .failure(.network(type: .noResponse))
}
}

public func patchPrimaryLanguage(
appId: String,
locale: AppStoreLanguage
) async -> Result<App, AppError> {
guard let client else { return .failure(.network(type: .unauthorized)) }

let requestData: AppUpdateRequest.Data = .init(
type: .apps,
id: appId,
attributes: .init(primaryLocale: locale.rawValue)
)
let request = Resources.v1.apps.id(appId).patch(.init(data: requestData))
do {
let data = try await client.send(request).data
guard let app = App(schema: data) else {
return .failure(.network(type: .decode))
}
return .success(app)
} catch {
let replacements = ["@@LANGUAGE_VALUE@@": locale.displayName]
return handleRequestFailure(error: error, replaces: replacements)
}
}
}

private extension AppsService {
Expand All @@ -219,4 +237,27 @@ private extension AppsService {
return App(schema: schema, included: filteredIncluded)
}
}

func handleRequestFailure<T>(error: Error, replaces: [String: String] = [:]) -> Result<T, AppError> {
if let responseError = error as? ResponseError {
switch responseError {
case let .requestFailure(errorResponse, _, _):
if let errors = errorResponse?.errors, let firstError = errors.first {
var title = firstError.title
var detail = firstError.detail

for (placeholder, replacement) in replaces {
title = title.replacingOccurrences(of: placeholder, with: replacement)
detail = detail.replacingOccurrences(of: placeholder, with: replacement)
}

return .failure(AppError.network(type: .apiError(title, detail)))
}
return .failure(AppError.network(type: .unknown))
default:
return .failure(AppError.network(type: .unknown))
}
}
return .failure(AppError.network(type: .unknown))
}
}
Loading

0 comments on commit 343fec6

Please sign in to comment.