Class: Feed::Google::MerchantApi::Client
- Inherits:
-
Object
- Object
- Feed::Google::MerchantApi::Client
- Defined in:
- app/services/feed/google/merchant_api/client.rb
Overview
Thin REST client for the Google Merchant API v1 (the GA successor to the
Content API for Shopping, which sunsets 2026-08-18).
Authenticates as a service account (scope
https://www.googleapis.com/auth/content) using the Heatwave::Configuration
credential at google.merchant.service_account — the same service-account
pattern Seo::GscApiClient uses. The GCP project must be registered with the
merchant account once via developerRegistration:registerGcp (see
doc/tasks/202606291024_MERCHANT_API_FEED_MIGRATION.md).
Uses the already-bundled googleauth + faraday rather than the heavier gRPC
google-shopping-merchant-* gems.
Feed is an ActiveRecord model, so this is defined with the compact
class Feed::Google::… form (matching GoogleProductPresenter)
rather than reopening module Feed.
Defined Under Namespace
Classes: ApiError, NotApiDataSourceError, TransientError
Constant Summary collapse
- BASE_URL =
'https://merchantapi.googleapis.com'- SCOPE =
'https://www.googleapis.com/auth/content'- TOKEN_REFRESH_SKEW =
Token is refreshed when it has under this long to live.
60
Instance Method Summary collapse
-
#account_name ⇒ String
E.g.
-
#create_primary_api_data_source!(feed_label:, country:, content_language: 'en', display_name: nil) ⇒ Hash
Create a primary API data source for a feed label/country.
-
#create_promotions_data_source!(target_country:, content_language: 'en', display_name: nil) ⇒ Hash
Create a promotions data source for a target country/language.
-
#data_sources ⇒ Array<Hash>
All data sources on the account.
-
#delete_product_input(name, data_source:) ⇒ Object
Delete a product input.
-
#each_product {|product| ... } ⇒ Object
Iterate processed products (post-rule view), yielding each.
-
#initialize(account_id: nil, service_account: nil, logger: Rails.logger) ⇒ Client
constructor
A new instance of Client.
-
#insert_product_input(product_input, data_source:) ⇒ Hash
Upsert one product input into a data source.
-
#insert_promotion(promotion, data_source:) ⇒ Hash
Upsert one promotion into a promotions data source.
-
#primary_api_data_source!(feed_label:, content_language: 'en') ⇒ String
The primary product data source for a feed label, requiring it to be an API-input source (the cutover target).
-
#promotions_data_source!(target_country:, content_language: 'en') ⇒ String
The promotions data source for a target country (promotions data sources are per-country/language, like products and local inventory).
-
#search_reports(query:, page_size: 1000) ⇒ Array<Hash>
Run a Report Language query against the Reports sub-API and return the flattened
productViewrows, paginating onpageToken(verified live 2026-07-29: the response shape matches #each_product's pagination).
Constructor Details
#initialize(account_id: nil, service_account: nil, logger: Rails.logger) ⇒ Client
Returns a new instance of Client.
51 52 53 54 55 56 57 58 |
# File 'app/services/feed/google/merchant_api/client.rb', line 51 def initialize(account_id: nil, service_account: nil, logger: Rails.logger) @account_id = (account_id || Heatwave::Configuration.fetch(:google, :merchant, :account_id)).to_s @service_account = service_account || Heatwave::Configuration.fetch(:google, :merchant, :service_account) @logger = logger @access_token_mutex = Mutex.new raise ApiError, 'google.merchant.account_id not configured' if @account_id.blank? raise ApiError, 'google.merchant.service_account not configured' if @service_account.blank? end |
Instance Method Details
#account_name ⇒ String
Returns e.g. "accounts/8613489".
61 62 63 |
# File 'app/services/feed/google/merchant_api/client.rb', line 61 def account_name "accounts/#{@account_id}" end |
#create_primary_api_data_source!(feed_label:, country:, content_language: 'en', display_name: nil) ⇒ Hash
Create a primary API data source for a feed label/country.
104 105 106 107 108 109 110 111 112 113 |
# File 'app/services/feed/google/merchant_api/client.rb', line 104 def create_primary_api_data_source!(feed_label:, country:, content_language: 'en', display_name: nil) post("/datasources/v1/#{account_name}/dataSources", { displayName: display_name || "WY-#{feed_label}-PRODUCTS (API)", primaryProductDataSource: { feedLabel: feed_label, contentLanguage: content_language, countries: [country] } }) end |
#create_promotions_data_source!(target_country:, content_language: 'en', display_name: nil) ⇒ Hash
Create a promotions data source for a target country/language.
147 148 149 150 151 152 |
# File 'app/services/feed/google/merchant_api/client.rb', line 147 def create_promotions_data_source!(target_country:, content_language: 'en', display_name: nil) post("/datasources/v1/#{account_name}/dataSources", { displayName: display_name || "WY-#{target_country} Promotions (API)", promotionDataSource: { targetCountry: target_country, contentLanguage: content_language } }) end |
#data_sources ⇒ Array<Hash>
All data sources on the account.
67 68 69 |
# File 'app/services/feed/google/merchant_api/client.rb', line 67 def data_sources get("/datasources/v1/#{account_name}/dataSources?pageSize=200").fetch('dataSources', []) end |
#delete_product_input(name, data_source:) ⇒ Object
Delete a product input. name is e.g. "accounts/123/productInputs/enUS42970".
124 125 126 |
# File 'app/services/feed/google/merchant_api/client.rb', line 124 def delete_product_input(name, data_source:) delete("/products/v1/#{name}?dataSource=#{data_source}") end |
#each_product {|product| ... } ⇒ Object
Iterate processed products (post-rule view), yielding each.
164 165 166 167 168 169 170 171 172 173 174 |
# File 'app/services/feed/google/merchant_api/client.rb', line 164 def each_product(&) page_token = nil loop do path = "/products/v1/#{account_name}/products?pageSize=1000" path += "&pageToken=#{CGI.escape(page_token)}" if page_token body = get(path) Array(body['products']).each(&) page_token = body['nextPageToken'] break if page_token.blank? end end |
#insert_product_input(product_input, data_source:) ⇒ Hash
Upsert one product input into a data source.
119 120 121 |
# File 'app/services/feed/google/merchant_api/client.rb', line 119 def insert_product_input(product_input, data_source:) post("/products/v1/#{account_name}/productInputs:insert?dataSource=#{data_source}", product_input) end |
#insert_promotion(promotion, data_source:) ⇒ Hash
Upsert one promotion into a promotions data source.
158 159 160 |
# File 'app/services/feed/google/merchant_api/client.rb', line 158 def insert_promotion(promotion, data_source:) post("/promotions/v1/#{account_name}/promotions:insert", { promotion:, dataSource: data_source }) end |
#primary_api_data_source!(feed_label:, content_language: 'en') ⇒ String
The primary product data source for a feed label, requiring it to be an
API-input source (the cutover target).
79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'app/services/feed/google/merchant_api/client.rb', line 79 def primary_api_data_source!(feed_label:, content_language: 'en') api_sources = data_sources.select { |d| d['primaryProductDataSource'] && d['input'] == 'API' } if api_sources.empty? # Pre-cutover: surface whether a FILE/FETCH primary still owns this label. file = data_sources.find { |d| d.dig('primaryProductDataSource', 'feedLabel') == feed_label } detail = file ? "#{file['displayName']} is still input=#{file['input']}" : 'no primary source' raise NotApiDataSourceError, "No API data source yet for feedLabel=#{feed_label} (#{detail}); " \ 'create one and cut over (see migration task doc).' end # Prefer a source scoped to this feed label; otherwise an unscoped API source # (one unscoped source carries every feed label — verified against the live # account), or any API source as a last resort. scoped = api_sources.find do |d| primary = d['primaryProductDataSource'] primary['feedLabel'] == feed_label && primary['contentLanguage'] == content_language end unscoped = api_sources.find { |d| d.dig('primaryProductDataSource', 'feedLabel').blank? } (scoped || unscoped || api_sources.first)['name'] end |
#promotions_data_source!(target_country:, content_language: 'en') ⇒ String
The promotions data source for a target country (promotions data sources are
per-country/language, like products and local inventory).
135 136 137 138 139 140 141 142 143 |
# File 'app/services/feed/google/merchant_api/client.rb', line 135 def promotions_data_source!(target_country:, content_language: 'en') src = data_sources.find do |d| promo = d['promotionDataSource'] promo && promo['targetCountry'] == target_country && promo['contentLanguage'] == content_language end raise ApiError, "No promotions data source for targetCountry=#{target_country} (create one first)." unless src src['name'] end |
#search_reports(query:, page_size: 1000) ⇒ Array<Hash>
Run a Report Language query against the Reports sub-API and return the
flattened productView rows, paginating on pageToken (verified live
2026-07-29: the response shape matches #each_product's pagination).
183 184 185 186 187 188 189 190 191 192 193 194 195 |
# File 'app/services/feed/google/merchant_api/client.rb', line 183 def search_reports(query:, page_size: 1000) rows = [] page_token = nil loop do body = { query:, pageSize: page_size } body[:pageToken] = page_token if page_token resp = post("/reports/v1/#{account_name}/reports:search", body) rows.concat(Array(resp['results']).filter_map { |r| r['productView'] }) page_token = resp['nextPageToken'] break if page_token.blank? end rows end |