Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions OpenASO.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
04E8C4E18F1A4D519C0A1104 /* AppIconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */; };
04E8C4E18F1A4D519C0A110A /* AppleAds/AppleAdsCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */; };
04E8C4E18F1A4D519C0A110B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110D /* SettingsView.swift */; };
04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */; };
04E8C4E18F1A4D519C0A1110 /* KeywordMetricsSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */; };
04E8C4E18F1A4D519C0A1111 /* AppleAds/KeywordMetricsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */; };
04E8C4E18F1A4D519C0A1112 /* SearchRanking/KeywordSuggestionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */; };
Expand Down Expand Up @@ -392,7 +391,6 @@
04E8C4E18F1A4D519C0A1108 /* AppIconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIconView.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleAdsCredentials.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A110D /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/AppleSearchAdsJWT.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A1114 /* KeywordMetricsSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeywordMetricsSource.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A1115 /* AppleAds/KeywordMetricsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAds/KeywordMetricsService.swift; sourceTree = "<group>"; };
04E8C4E18F1A4D519C0A1116 /* SearchRanking/KeywordSuggestionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRanking/KeywordSuggestionService.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -837,7 +835,6 @@
A0BEEF000000000000000012 /* Storefront/ScreenshotDownloadService.swift */,
FA05C59C087130E05FF3D085 /* SearchRanking/AppResolver.swift */,
04E8C4E18F1A4D519C0A110C /* AppleAds/AppleAdsCredentials.swift */,
04E8C4E18F1A4D519C0A110F /* AppleAds/AppleSearchAdsJWT.swift */,
ADF000000000000000000002 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift */,
04E8C4E18F1A4D519C0A1124 /* AppleAds/AppleAdsWebSession.swift */,
B9A0000000000000000004 /* AppleAds/AppleAdsPastedSession.swift */,
Expand Down Expand Up @@ -1322,7 +1319,6 @@
ADF000000000000000000001 /* AppleAds/AppleAdsPopularityFallbackMessaging.swift in Sources */,
B9A0000000000000000003 /* AppleAds/AppleAdsPastedSession.swift in Sources */,
B9A0000000000000000001 /* AppleAds/AppleAdsWebLoginController.swift in Sources */,
04E8C4E18F1A4D519C0A110E /* AppleAds/AppleSearchAdsJWT.swift in Sources */,
A0D71B4C0000000000000009 /* AppDetailSupportTypes.swift in Sources */,
A0D71B4C0000000000000019 /* AppDetailPreviewFixtures.swift in Sources */,
A0D71B4C000000000000001B /* AppDetailCSVTransfer.swift in Sources */,
Expand Down
4 changes: 3 additions & 1 deletion OpenASO/App/AppServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ final class AppServices {
namespace: namespace,
loadsEnvironmentCredentials: loadsEnvironmentCredentials
)
let appleAdsPlatformAPI = appleAdsPlatformAPI ?? OfficialAppleAdsPlatformAPI()
let appleAdsPlatformAPI = appleAdsPlatformAPI ?? OfficialAppleAdsPlatformAPI(
httpClient: httpClient
)
let settingsStore = AppSettingsStore(defaults: defaults)
let backgroundRefreshAgentController = BackgroundRefreshAgentController(
defaults: defaults
Expand Down
69 changes: 69 additions & 0 deletions OpenASO/Services/AppleAds/AppleAdsPlatformService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppleAdsClient
import Foundation
import HTTPTypes
import OpenAPIRuntime

struct AppleAdsPlatformAccount: Codable, Equatable, Identifiable, Sendable {
Expand Down Expand Up @@ -170,8 +171,72 @@ protocol AppleAdsPlatformAPI: Sendable {
) async throws -> [AppleAdsKeywordSuggestion]
}

/// Sends generated client requests through the provider pipeline, so Platform API
/// calls share its pacing, retry, and refresh observation behavior.
struct AppleAdsHTTPClientTransport: ClientTransport {
private static let maximumRequestBodyBytes = 5_000_000

let httpClient: any HTTPClient

func send(
_ request: HTTPRequest,
body: HTTPBody?,
baseURL: URL,
operationID: String
) async throws -> (HTTPResponse, HTTPBody?) {
var urlRequest = try await Self.urlRequest(for: request, body: body, baseURL: baseURL)
urlRequest.cachePolicy = .reloadIgnoringLocalCacheData
urlRequest.httpShouldHandleCookies = false

let (data, response) = try await httpClient.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw OpenASOError.unexpectedResponse
}
return (Self.response(from: httpResponse), HTTPBody(data))
}

private static func urlRequest(
for request: HTTPRequest,
body: HTTPBody?,
baseURL: URL
) async throws -> URLRequest {
guard let url = URL(string: baseURL.absoluteString + (request.path ?? "")) else {
throw OpenASOError.unexpectedResponse
}

var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
for field in request.headerFields {
urlRequest.addValue(field.value, forHTTPHeaderField: field.name.rawName)
}
if let body {
urlRequest.httpBody = try await Data(collecting: body, upTo: maximumRequestBodyBytes)
}
return urlRequest
}

private static func response(from response: HTTPURLResponse) -> HTTPResponse {
var headerFields = HTTPFields()
for (name, value) in response.allHeaderFields {
guard let name = name as? String,
let value = value as? String,
let fieldName = HTTPField.Name(name) else { continue }
headerFields.append(HTTPField(name: fieldName, value: value))
}
return HTTPResponse(
status: HTTPResponse.Status(code: response.statusCode),
headerFields: headerFields
)
}
}

struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI {
let coverage = AppleAdsPlatformCoverage.current
private let httpClient: any HTTPClient

init(httpClient: any HTTPClient = URLSessionHTTPClient()) {
self.httpClient = httpClient
}

func keywordSuggestions(
appStoreID: Int64,
Expand Down Expand Up @@ -547,11 +612,15 @@ struct OfficialAppleAdsPlatformAPI: AppleAdsPlatformAPI {
do {
return try await AppleAdsClient.withClient(
configuration: configuration,
transport: AppleAdsHTTPClientTransport(httpClient: httpClient),
body: operation
)
} catch let error as OpenASOError {
throw error
} catch {
if Task.isCancelled {
throw CancellationError()
}
throw OpenASOError.providerUnavailable(
"Apple Ads Platform API: \(error.localizedDescription)"
)
Expand Down
71 changes: 0 additions & 71 deletions OpenASO/Services/AppleAds/AppleSearchAdsJWT.swift

This file was deleted.

2 changes: 1 addition & 1 deletion OpenASO/Services/MCP/OpenASOMCPRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ enum OpenASOMCPRuntime {
namespace: namespace
)
}
let appleAdsPlatformAPI = OfficialAppleAdsPlatformAPI()
let appleAdsPlatformAPI = OfficialAppleAdsPlatformAPI(httpClient: httpClient)
let mcpService = OpenASOMCPService(
backgroundModelStore: backgroundModelStore,
keywordResearchProjectStore: keywordResearchProjectStore,
Expand Down
7 changes: 6 additions & 1 deletion OpenASO/Services/MCP/OpenASOMCPService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -912,6 +912,7 @@ final class OpenASOMCPService: Sendable {

func getAppOverview(appStoreID: Int64) async throws -> OpenASOMCPAppOverview {
let appStoreID = try OpenASOMCPValidation.appStoreID(appStoreID)
let asOf = now()
return try await backgroundModelStore.read { modelContext in
guard let storeApp = try Self.fetchStoreApp(appStoreID: appStoreID, in: modelContext) else {
throw OpenASOError.appNotFound
Expand Down Expand Up @@ -943,6 +944,7 @@ final class OpenASOMCPService: Sendable {
storefronts: [],
platform: nil,
lookbackDays: 180,
asOf: asOf,
limit: ResponseLimits.overviewCompetitors,
evidenceLimit: ResponseLimits.overviewCompetitorEvidence,
in: modelContext
Expand Down Expand Up @@ -2314,12 +2316,14 @@ final class OpenASOMCPService: Sendable {
default: ResponseLimits.defaultCompetitorEvidence,
maximum: ResponseLimits.maximumCompetitorEvidence
)
let asOf = now()
return try await backgroundModelStore.read { modelContext in
try Self.deriveCompetitors(
appStoreID: appStoreID,
storefronts: storefronts,
platform: platform,
lookbackDays: lookbackDays,
asOf: asOf,
limit: limit,
evidenceLimit: evidenceLimit,
in: modelContext
Expand Down Expand Up @@ -5473,6 +5477,7 @@ extension OpenASOMCPService {
storefronts: [String],
platform: AppPlatform?,
lookbackDays: Int,
asOf: Date,
limit: Int,
evidenceLimit: Int,
in modelContext: ModelContext
Expand All @@ -5481,7 +5486,7 @@ extension OpenASOMCPService {
let queryKeys = trackedApp.map { Set($0.keywordTracks.map(\.queryKey)) } ?? []
guard !queryKeys.isEmpty else { return [] }

let cutoff = Date().addingTimeInterval(-Double(max(1, lookbackDays)) * 86_400)
let cutoff = asOf.addingTimeInterval(-Double(max(1, lookbackDays)) * 86_400)
let storefrontSet = Set(storefronts)
let descriptor = FetchDescriptor<RankingFact>(
predicate: #Predicate { ranking in
Expand Down
16 changes: 16 additions & 0 deletions OpenASO/Services/Networking/ProviderRequestGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,13 @@ actor ProviderRequestGate: HTTPClient {
return .permanent
}

/// Read-only Platform API queries that OpenASO sends as POST requests.
private static let appleAdsPlatformQueryPaths = [
"/v1/campaigns/query",
"/v1/insights/apps/search-term-popularity/query",
"/v1/suggestions/keywords/query",
]

private static func behavior(for request: URLRequest) -> RequestBehavior {
guard request.httpBodyStream == nil else {
return .paceOnly
Expand All @@ -863,6 +870,15 @@ actor ProviderRequestGate: HTTPClient {
requiresAdamIDQuery: false
):
return .retryOnly
case "POST" where appleAdsPlatformQueryPaths.contains(where: { path in
isHTTPSRequest(
request,
host: "api.ads.apple.com",
path: path,
requiresAdamIDQuery: false
)
}):
return .retryOnly
default:
return .paceOnly
}
Expand Down
2 changes: 1 addition & 1 deletion OpenASO/Services/Networking/RefreshObservability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ struct RefreshRequestClassification: Sendable {
case "app-ads.apple.com":
provider = .appleAdsWeb
endpoint = path.contains("/keywords/popularities") ? .appleAdsPopularity : .other
case "api.searchads.apple.com":
case "api.ads.apple.com":
provider = .appleAdsAPI
endpoint = .appleAdsAPI
case "appleid.apple.com":
Expand Down
80 changes: 80 additions & 0 deletions OpenASOTests/AppleAdsPlatformServiceTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,59 @@
import Foundation
import HTTPTypes
import OpenAPIRuntime
import Testing
@testable import OpenASO

struct AppleAdsPlatformServiceTests {
@Test
func transportSendsPlatformRequestsThroughTheHTTPClient() async throws {
let client = RecordingAppleAdsHTTPClient(
statusCode: 200,
headers: ["Content-Type": "application/json", "RateLimit-Remaining": "9"],
body: Data(#"{"result":[]}"#.utf8)
)
let transport = AppleAdsHTTPClientTransport(httpClient: client)
var request = HTTPRequest(method: .post, scheme: nil, authority: nil, path: "/campaigns/query?limit=1")
request.headerFields[.authorization] = "Bearer token"
request.headerFields[try #require(HTTPField.Name("X-AP-Context"))] = "adAccountId=42"

let (response, body) = try await transport.send(
request,
body: HTTPBody(#"{"pagination":{}}"#),
baseURL: try #require(URL(string: "https://api.ads.apple.com/v1")),
operationID: "postCampaignsQuery"
)

let sent = try #require(client.recordedRequest)
#expect(sent.url?.absoluteString == "https://api.ads.apple.com/v1/campaigns/query?limit=1")
#expect(sent.httpMethod == "POST")
#expect(sent.httpBody == Data(#"{"pagination":{}}"#.utf8))
#expect(sent.httpBodyStream == nil)
#expect(sent.value(forHTTPHeaderField: "Authorization") == "Bearer token")
#expect(sent.value(forHTTPHeaderField: "X-AP-Context") == "adAccountId=42")
#expect(sent.httpShouldHandleCookies == false)
#expect(response.status == .ok)
#expect(response.headerFields[try #require(HTTPField.Name("RateLimit-Remaining"))] == "9")
let responseBody = try await Data(collecting: try #require(body), upTo: 1_000)
#expect(responseBody == Data(#"{"result":[]}"#.utf8))
}

@Test
func transportReturnsErrorStatusesForTheGeneratedClientToHandle() async throws {
let client = RecordingAppleAdsHTTPClient(statusCode: 429, headers: [:], body: Data())
let transport = AppleAdsHTTPClientTransport(httpClient: client)

let (response, _) = try await transport.send(
HTTPRequest(method: .get, scheme: nil, authority: nil, path: "/me"),
body: nil,
baseURL: try #require(URL(string: "https://api.ads.apple.com/v1")),
operationID: "getMe"
)

#expect(response.status.code == 429)
#expect(client.recordedRequest?.httpBody == nil)
}

@Test
func keywordSuggestionRequestKeepsAppMarketAndSeedWithoutRejectedSorting() throws {
let request = OfficialAppleAdsPlatformAPI.keywordSuggestionsRequest(
Expand Down Expand Up @@ -308,3 +359,32 @@ struct AppleAdsPlatformServiceTests {
#expect(names.contains("apple_ads_search_term_popularity"))
}
}

private final class RecordingAppleAdsHTTPClient: HTTPClient, @unchecked Sendable {
private let lock = NSLock()
private let statusCode: Int
private let headers: [String: String]
private let body: Data
private var storedRequest: URLRequest?

init(statusCode: Int, headers: [String: String], body: Data) {
self.statusCode = statusCode
self.headers = headers
self.body = body
}

var recordedRequest: URLRequest? {
lock.withLock { storedRequest }
}

func data(for request: URLRequest) async throws -> (Data, URLResponse) {
lock.withLock { storedRequest = request }
let response = HTTPURLResponse(
url: request.url!,
statusCode: statusCode,
httpVersion: nil,
headerFields: headers
)!
return (body, response)
}
}
Loading
Loading