diff --git a/README.md b/README.md index bb734bb..fe45ace 100644 --- a/README.md +++ b/README.md @@ -732,3 +732,27 @@ Questions and issues: [fopost.com/contact](https://fopost.com/contact) or the [i ## License MIT. Copyright Porter Bridge, LLC. + +### Google Ads + +Campaigns, ad groups, ads, audiences and insights are on `$client->ads()` and dispatch by +connection. What only Google has is under `$client->ads()->google()`: + +```php +$keywords = $client->ads()->google()->keywords('c4d5e6f7-…', '1234567890'); + +$client->ads()->google()->createKeyword( + workspaceId: '7d2b8c11-…', + connectionId: 'c4d5e6f7-…', + customerId: '1234567890', + adGroupId: '1234567890~adGroup~77', + text: 'running shoes', + matchType: 'EXACT', +); +``` + +Also `keywordIdeas()`, `keywordMetrics()`, `searchTerms()`, `bidStrategies()`, +`adSchedule()` and `setAdSchedule()`, the negative keyword lists, `assets()` and +`assetGroups()`, `localServicesLeads()`, the conversion methods, and `query()` for a raw +read-only GAQL SELECT. Changes need the `publish` scope as well as `ads`; the customer id +has to name an account the connection's grant reaches. diff --git a/src/Model/GoogleAdScheduleSlot.php b/src/Model/GoogleAdScheduleSlot.php new file mode 100644 index 0000000..482c89b --- /dev/null +++ b/src/Model/GoogleAdScheduleSlot.php @@ -0,0 +1,34 @@ + */ + public readonly array $finalUrls, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'id'), + self::requiredStr($data, 'campaignId'), + self::requiredStr($data, 'name'), + self::requiredStr($data, 'status'), + array_values(array_filter(self::seq($data, 'finalUrls'), 'is_string')), + ); + } +} diff --git a/src/Model/GoogleAssetLink.php b/src/Model/GoogleAssetLink.php new file mode 100644 index 0000000..4074d40 --- /dev/null +++ b/src/Model/GoogleAssetLink.php @@ -0,0 +1,36 @@ + $assets + * @param array $links + */ + private function __construct( + array $raw, + public readonly array $assets, + public readonly array $links, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + GoogleAsset::listFrom(self::seq($data, 'assets')), + GoogleAssetLink::listFrom(self::seq($data, 'links')), + ); + } +} diff --git a/src/Model/GoogleBidStrategy.php b/src/Model/GoogleBidStrategy.php new file mode 100644 index 0000000..44ecb56 --- /dev/null +++ b/src/Model/GoogleBidStrategy.php @@ -0,0 +1,34 @@ +~keyword~~`. */ +final class GoogleKeyword extends Model +{ + private function __construct( + array $raw, + public readonly string $id, + public readonly string $adGroupId, + public readonly string $text, + public readonly string $matchType, + public readonly string $status, + public readonly ?int $cpcBidMinor, + public readonly ?bool $negative, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'id'), + self::requiredStr($data, 'adGroupId'), + self::requiredStr($data, 'text'), + self::requiredStr($data, 'matchType'), + self::requiredStr($data, 'status'), + self::int($data, 'cpcBidMinor'), + self::bool($data, 'negative'), + ); + } +} diff --git a/src/Model/GoogleKeywordIdea.php b/src/Model/GoogleKeywordIdea.php new file mode 100644 index 0000000..416b658 --- /dev/null +++ b/src/Model/GoogleKeywordIdea.php @@ -0,0 +1,34 @@ + */ + public readonly array $metrics, + ) { + parent::__construct($raw); + } + + public static function fromArray(mixed $data): static + { + $data = is_array($data) ? $data : []; + + return new self( + $data, + self::requiredStr($data, 'term'), + self::str($data, 'adGroupId'), + self::str($data, 'status'), + self::map($data, 'metrics'), + ); + } +} diff --git a/src/Model/GoogleSharedSet.php b/src/Model/GoogleSharedSet.php new file mode 100644 index 0000000..ee5e305 --- /dev/null +++ b/src/Model/GoogleSharedSet.php @@ -0,0 +1,32 @@ +ads(): Meta ads, catalogs, audiences, the ad archive and lead forms. + * $client->ads(): ads, catalogs, audiences, the ad archive and lead forms. + * + * Meta is what this resource covers; the Google-only surface is ads()->google(). * * Every call needs the `ads` scope. boost(), create(), setStatus(), delete(), bulkSetStatus() and the * create, update, delete and duplicate calls on campaigns, ad sets and network ads spend money and @@ -55,6 +57,14 @@ */ final class AdsResource extends Resource { + private ?GoogleAdsResource $google = null; + + /** The Search surface no other network has: keywords, assets, conversions, GAQL. */ + public function google(): GoogleAdsResource + { + return $this->google ??= new GoogleAdsResource($this->http); + } + /** * Boosts and ads created through FoPost, with insights from their last refresh. * @@ -118,6 +128,18 @@ public function authorizeMeta(string $workspaceId, ?string $method = null, ?stri return is_array($result) && is_string($result['url'] ?? null) ? $result['url'] : ''; } + /** The Google login URL; the caller finishes it in their own browser. */ + public function authorizeGoogle(string $workspaceId, ?string $returnTo = null): string + { + $body = self::compact([ + 'workspaceId' => $workspaceId, + 'returnTo' => $returnTo, + ]); + $result = self::unwrap($this->http->post('/ads/connections/google/authorize', $body)); + + return is_array($result) && is_string($result['url'] ?? null) ? $result['url'] : ''; + } + /** Also deletes every ad record created through the connection. */ public function deleteConnection(string $connectionId, string $workspaceId): void { diff --git a/src/Resource/GoogleAdsResource.php b/src/Resource/GoogleAdsResource.php new file mode 100644 index 0000000..1753eb2 --- /dev/null +++ b/src/Resource/GoogleAdsResource.php @@ -0,0 +1,580 @@ +ads()->google(): the Google Ads surface no other network has. + * + * Campaigns, ad groups, ads, audiences and insights are on the ads resource itself and dispatch by + * connection. What is here — keywords, assets, Performance Max asset groups, Local Services leads, + * conversions and raw GAQL — is Google only, and a connection on another network answers 400. + * + * Every call needs the `ads` scope; anything that changes what a live account serves or bids also + * needs `publish`. $customerId is digits only and has to name an account the connection's grant + * reaches: any other answers 404. + */ +final class GoogleAdsResource extends Resource +{ + // ── Keywords ── + + /** + * Keywords on the account, or on one ad group. + * + * @return array + */ + public function keywords( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ?string $adGroupId = null, + ): array { + return GoogleKeyword::listFrom(self::unwrap($this->http->get( + '/ads/google/keywords', + self::params($connectionId, $customerId, $workspaceId, ['ad_group_id' => $adGroupId]), + ))); + } + + /** The new keyword's id. Needs the `publish` scope as well as `ads`. */ + public function createKeyword( + string $workspaceId, + string $connectionId, + string $customerId, + string $adGroupId, + string $text, + string $matchType, + ?int $cpcBidMinor = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'adGroupId' => $adGroupId, + 'text' => $text, + 'matchType' => $matchType, + 'cpcBidMinor' => $cpcBidMinor, + ]); + + return self::id($this->http->post('/ads/google/keywords', $body)); + } + + /** $status is `active` or `paused`. Needs the `publish` scope as well as `ads`. */ + public function updateKeyword( + string $keywordId, + string $workspaceId, + string $connectionId, + string $customerId, + ?string $status = null, + ?int $cpcBidMinor = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'status' => $status, + 'cpcBidMinor' => $cpcBidMinor, + ]); + + return self::id($this->http->request('PATCH', "/ads/google/keywords/{$keywordId}", $body)); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function deleteKeyword( + string $keywordId, + string $workspaceId, + string $connectionId, + string $customerId, + ): void { + $this->http->request( + 'DELETE', + "/ads/google/keywords/{$keywordId}", + self::scope($workspaceId, $connectionId, $customerId), + ); + } + + /** + * Ideas from seed keywords, a landing page, or both. + * + * @param array|null $seeds + * @param array|null $geoTargetIds + * @return array + */ + public function keywordIdeas( + string $workspaceId, + string $connectionId, + string $customerId, + ?array $seeds = null, + ?string $url = null, + ?string $languageId = null, + ?array $geoTargetIds = null, + ): array { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'seeds' => $seeds, + 'url' => $url, + 'languageId' => $languageId, + 'geoTargetIds' => $geoTargetIds, + ]); + + return GoogleKeywordIdea::listFrom( + self::unwrap($this->http->post('/ads/google/keyword-ideas', $body)), + ); + } + + /** + * @param array $keywords + * @return array + */ + public function keywordMetrics( + string $workspaceId, + string $connectionId, + string $customerId, + array $keywords, + ): array { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['keywords' => $keywords]; + + return GoogleKeywordIdea::listFrom( + self::unwrap($this->http->post('/ads/google/keyword-metrics', $body)), + ); + } + + /** + * What people actually searched, with the metrics each term earned. + * + * @return array + */ + public function searchTerms( + string $connectionId, + string $customerId, + string $since, + string $until, + ?string $workspaceId = null, + ): array { + return GoogleSearchTerm::listFrom(self::unwrap($this->http->get( + '/ads/google/search-terms', + self::params($connectionId, $customerId, $workspaceId, ['since' => $since, 'until' => $until]), + ))); + } + + // ── Bid strategies and ad schedule ── + + /** @return array */ + public function bidStrategies( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): array { + return GoogleBidStrategy::listFrom(self::unwrap($this->http->get( + '/ads/google/bid-strategies', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function createBidStrategy( + string $workspaceId, + string $connectionId, + string $customerId, + string $name, + string $type, + ?int $targetMinor = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'name' => $name, + 'type' => $type, + 'targetMinor' => $targetMinor, + ]); + + return self::id($this->http->post('/ads/google/bid-strategies', $body)); + } + + /** @return array */ + public function adSchedule( + string $connectionId, + string $customerId, + string $campaignId, + ?string $workspaceId = null, + ): array { + return GoogleAdScheduleSlot::listFrom(self::unwrap($this->http->get( + '/ads/google/ad-schedule', + self::params($connectionId, $customerId, $workspaceId, ['campaign_id' => $campaignId]), + ))); + } + + /** + * Replaces every slot on the campaign: Google has no partial edit for a schedule. + * Needs the `publish` scope as well as `ads`. + * + * @param array> $slots + */ + public function setAdSchedule( + string $workspaceId, + string $connectionId, + string $customerId, + string $campaignId, + array $slots, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + [ + 'campaignId' => $campaignId, + 'slots' => $slots, + ]; + $result = self::unwrap($this->http->request('PUT', '/ads/google/ad-schedule', $body)); + + return is_array($result) && is_int($result['slots'] ?? null) ? $result['slots'] : 0; + } + + // ── Negative keyword lists ── + + /** @return array */ + public function negativeKeywordLists( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): array { + return GoogleSharedSet::listFrom(self::unwrap($this->http->get( + '/ads/google/negative-keywords', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function createNegativeKeywordList( + string $workspaceId, + string $connectionId, + string $customerId, + string $name, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['name' => $name]; + + return self::id($this->http->post('/ads/google/negative-keywords', $body)); + } + + /** + * How many were added. Needs the `publish` scope as well as `ads`. + * + * @param array> $keywords + */ + public function addNegativeKeywords( + string $workspaceId, + string $connectionId, + string $customerId, + string $sharedSetId, + array $keywords, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + [ + 'sharedSetId' => $sharedSetId, + 'keywords' => $keywords, + ]; + $result = self::unwrap($this->http->post('/ads/google/negative-keywords/keywords', $body)); + + return is_array($result) && is_int($result['added'] ?? null) ? $result['added'] : 0; + } + + /** Needs the `publish` scope as well as `ads`. */ + public function attachNegativeKeywordList( + string $workspaceId, + string $connectionId, + string $customerId, + string $sharedSetId, + string $campaignId, + ): void { + $body = self::scope($workspaceId, $connectionId, $customerId) + [ + 'sharedSetId' => $sharedSetId, + 'campaignId' => $campaignId, + ]; + $this->http->post('/ads/google/negative-keywords/attach', $body); + } + + // ── Assets ── + + /** Sitelinks, callouts and snippets, with the links that put each one under an ad. */ + public function assets( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): GoogleAssetsResult { + return GoogleAssetsResult::fromArray(self::unwrap($this->http->get( + '/ads/google/assets', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** + * $spec is a sitelink, callout or snippet. Needs the `publish` scope as well as `ads`. + * + * @param array $spec + */ + public function createAsset( + string $workspaceId, + string $connectionId, + string $customerId, + array $spec, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['spec' => $spec]; + + return self::id($this->http->post('/ads/google/assets', $body)); + } + + /** Attaches to the account when $campaignId is left out. Needs `publish`. */ + public function attachAsset( + string $workspaceId, + string $connectionId, + string $customerId, + string $assetId, + string $fieldType, + ?string $campaignId = null, + ): void { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'assetId' => $assetId, + 'fieldType' => $fieldType, + 'campaignId' => $campaignId, + ]); + $this->http->post('/ads/google/assets/attach', $body); + } + + /** + * Removes the links that put the asset under an ad; on Google the asset itself is permanent. + * Needs the `publish` scope as well as `ads`. + */ + public function deleteAsset( + string $assetId, + string $workspaceId, + string $connectionId, + string $customerId, + ): void { + $this->http->request( + 'DELETE', + "/ads/google/assets/{$assetId}", + self::scope($workspaceId, $connectionId, $customerId), + ); + } + + // ── Performance Max asset groups ── + + /** @return array */ + public function assetGroups( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ?string $campaignId = null, + ): array { + return GoogleAssetGroup::listFrom(self::unwrap($this->http->get( + '/ads/google/asset-groups', + self::params($connectionId, $customerId, $workspaceId, ['campaign_id' => $campaignId]), + ))); + } + + /** + * Starts paused unless $status says otherwise. Needs `publish` as well as `ads`. + * + * @param array $finalUrls + */ + public function createAssetGroup( + string $workspaceId, + string $connectionId, + string $customerId, + string $campaignId, + string $name, + array $finalUrls, + ?string $status = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'campaignId' => $campaignId, + 'name' => $name, + 'finalUrls' => $finalUrls, + 'status' => $status, + ]); + + return self::id($this->http->post('/ads/google/asset-groups', $body)); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function updateAssetGroup( + string $assetGroupId, + string $workspaceId, + string $connectionId, + string $customerId, + ?string $name = null, + ?string $status = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'name' => $name, + 'status' => $status, + ]); + + return self::id( + $this->http->request('PATCH', "/ads/google/asset-groups/{$assetGroupId}", $body), + ); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function deleteAssetGroup( + string $assetGroupId, + string $workspaceId, + string $connectionId, + string $customerId, + ): void { + $this->http->request( + 'DELETE', + "/ads/google/asset-groups/{$assetGroupId}", + self::scope($workspaceId, $connectionId, $customerId), + ); + } + + // ── Local Services leads ── + + /** + * Read live on every call and never stored by FoPost. + * + * @return array + */ + public function localServicesLeads( + string $connectionId, + string $customerId, + string $since, + string $until, + ?string $workspaceId = null, + ): array { + return GoogleLocalServicesLead::listFrom(self::unwrap($this->http->get( + '/ads/google/local-services', + self::params($connectionId, $customerId, $workspaceId, ['since' => $since, 'until' => $until]), + ))); + } + + // ── Conversions ── + + /** @return array */ + public function conversionActions( + string $connectionId, + string $customerId, + ?string $workspaceId = null, + ): array { + return GoogleConversionAction::listFrom(self::unwrap($this->http->get( + '/ads/google/conversions', + self::params($connectionId, $customerId, $workspaceId), + ))); + } + + /** Needs the `publish` scope as well as `ads`. */ + public function createConversionAction( + string $workspaceId, + string $connectionId, + string $customerId, + string $name, + string $category, + ?int $valueMinor = null, + ?string $countingType = null, + ): string { + $body = self::scope($workspaceId, $connectionId, $customerId) + self::compact([ + 'name' => $name, + 'category' => $category, + 'valueMinor' => $valueMinor, + 'countingType' => $countingType, + ]); + + return self::id($this->http->post('/ads/google/conversions', $body)); + } + + /** + * Offline conversions, matched to a click. Needs `publish` as well as `ads`. + * + * @param array> $conversions + */ + public function uploadConversions( + string $workspaceId, + string $connectionId, + string $customerId, + array $conversions, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['conversions' => $conversions]; + + return self::uploaded($this->http->post('/ads/google/conversions/upload', $body)); + } + + /** + * Needs the `publish` scope as well as `ads`. + * + * @param array> $adjustments + */ + public function uploadConversionAdjustments( + string $workspaceId, + string $connectionId, + string $customerId, + array $adjustments, + ): int { + $body = self::scope($workspaceId, $connectionId, $customerId) + ['adjustments' => $adjustments]; + + return self::uploaded($this->http->post('/ads/google/conversions/adjustments', $body)); + } + + // ── GAQL ── + + /** + * A read-only GAQL SELECT; rows come back exactly as Google returns them. + * + * @return array> + */ + public function query( + string $connectionId, + string $customerId, + string $query, + ?string $workspaceId = null, + ): array { + $body = self::compact([ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'customerId' => $customerId, + 'query' => $query, + ]); + $result = self::unwrap($this->http->post('/ads/insights/query', $body)); + $rows = is_array($result) ? ($result['rows'] ?? null) : null; + + return is_array($rows) ? array_values(array_filter($rows, 'is_array')) : []; + } + + /** @return array */ + private static function scope(string $workspaceId, string $connectionId, string $customerId): array + { + return [ + 'workspaceId' => $workspaceId, + 'connectionId' => $connectionId, + 'customerId' => $customerId, + ]; + } + + /** + * @param array $extra + * @return array + */ + private static function params( + string $connectionId, + string $customerId, + ?string $workspaceId, + array $extra = [], + ): array { + return self::compact([ + 'workspace_id' => $workspaceId, + 'connection_id' => $connectionId, + 'customer_id' => $customerId, + ] + $extra); + } + + private static function id(mixed $body): string + { + $result = self::unwrap($body); + + return is_array($result) && is_string($result['id'] ?? null) ? $result['id'] : ''; + } + + private static function uploaded(mixed $body): int + { + $result = self::unwrap($body); + + return is_array($result) && is_int($result['uploaded'] ?? null) ? $result['uploaded'] : 0; + } +} diff --git a/tests/GoogleAdsTest.php b/tests/GoogleAdsTest.php new file mode 100644 index 0000000..bf84c26 --- /dev/null +++ b/tests/GoogleAdsTest.php @@ -0,0 +1,111 @@ +transport->push(200, ['data' => [[ + 'id' => '1234567890~keyword~77~99', + 'adGroupId' => '1234567890~adGroup~77', + 'text' => 'running shoes', + 'matchType' => 'EXACT', + 'status' => 'ENABLED', + 'cpcBidMinor' => 180, + 'negative' => false, + ]]]); + + $keywords = $this->client()->ads()->google()->keywords( + 'conn_1', + '1234567890', + 'ws_1', + '1234567890~adGroup~77', + ); + + $this->assertCount(1, $keywords); + $this->assertSame('running shoes', $keywords[0]->text); + $this->assertSame(180, $keywords[0]->cpcBidMinor); + + $url = $this->transport->last()['url']; + $this->assertStringContainsString('/ads/google/keywords', $url); + $this->assertStringContainsString('connection_id=conn_1', $url); + $this->assertStringContainsString('customer_id=1234567890', $url); + } + + public function testCreateKeywordSendsACamelCaseBody(): void + { + $this->transport->push(201, ['data' => ['id' => '1234567890~keyword~77~99']]); + + $id = $this->client()->ads()->google()->createKeyword( + 'ws_1', + 'conn_1', + '1234567890', + '1234567890~adGroup~77', + 'running shoes', + 'EXACT', + ); + + $this->assertSame('1234567890~keyword~77~99', $id); + $body = $this->transport->lastJson(); + $this->assertSame('1234567890~adGroup~77', $body['adGroupId']); + $this->assertSame('EXACT', $body['matchType']); + $this->assertSame('1234567890', $body['customerId']); + } + + public function testDeleteCarriesTheScopeInTheBody(): void + { + $this->transport->push(204, null); + + $this->client()->ads()->google()->deleteAsset('1234567890~asset~4321', 'ws_1', 'conn_1', '1234567890'); + + $this->assertSame('DELETE', $this->transport->last()['method']); + $this->assertSame([ + 'workspaceId' => 'ws_1', + 'connectionId' => 'conn_1', + 'customerId' => '1234567890', + ], $this->transport->lastJson()); + } + + public function testAdScheduleIsReplacedWithPut(): void + { + $this->transport->push(200, ['data' => ['slots' => 2]]); + + $slots = $this->client()->ads()->google()->setAdSchedule( + 'ws_1', + 'conn_1', + '1234567890', + '1234567890~campaign~55', + [['dayOfWeek' => 'MONDAY', 'startHour' => 9, 'endHour' => 18]], + ); + + $this->assertSame(2, $slots); + $this->assertSame('PUT', $this->transport->last()['method']); + } + + public function testQueryReturnsRowsAsGoogleSendsThem(): void + { + $this->transport->push(200, ['data' => ['rows' => [['campaign' => ['id' => '55']]]]]); + + $rows = $this->client()->ads()->google()->query( + 'conn_1', + '1234567890', + 'SELECT campaign.id FROM campaign', + ); + + $this->assertSame([['campaign' => ['id' => '55']]], $rows); + $this->assertStringContainsString('/ads/insights/query', $this->transport->last()['url']); + } + + public function testAuthorizeGoogleHasItsOwnRoute(): void + { + $this->transport->push(200, ['data' => ['url' => 'https://accounts.google.com/o/x']]); + + $url = $this->client()->ads()->authorizeGoogle('ws_1'); + + $this->assertSame('https://accounts.google.com/o/x', $url); + $this->assertStringContainsString('/ads/connections/google/authorize', $this->transport->last()['url']); + } +}