Class: Feed::Google::MerchantApi::Client

Inherits:
Object
  • Object
show all
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.

Examples:

Push a product input

client = Feed::Google::MerchantApi::Client.new
ds = client.primary_api_data_source!(feed_label: 'US')
client.insert_product_input(input_hash, data_source: ds)

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

Constructor Details

#initialize(account_id: nil, service_account: nil, logger: Rails.logger) ⇒ Client

Returns a new instance of Client.

Parameters:

  • account_id (String, nil) (defaults to: nil)

    Merchant Center account id; defaults to
    google.merchant.account_id in credentials.

  • service_account (Hash, nil) (defaults to: nil)

    service-account key hash; defaults to
    google.merchant.service_account in credentials.

  • logger (Logger) (defaults to: Rails.logger)

Raises:



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 = ( || Heatwave::Configuration.fetch(:google, :merchant, :account_id)).to_s
  @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_nameString

Returns e.g. "accounts/8613489".

Returns:

  • (String)

    e.g. "accounts/8613489"



61
62
63
# File 'app/services/feed/google/merchant_api/client.rb', line 61

def 
  "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.

Returns:

  • (Hash)

    the created data source



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/#{}/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.

Returns:

  • (Hash)

    the created data source



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/#{}/dataSources", {
         displayName: display_name || "WY-#{target_country} Promotions (API)",
         promotionDataSource: { targetCountry: target_country, contentLanguage: content_language }
       })
end

#data_sourcesArray<Hash>

All data sources on the account.

Returns:

  • (Array<Hash>)


67
68
69
# File 'app/services/feed/google/merchant_api/client.rb', line 67

def data_sources
  get("/datasources/v1/#{}/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.

Yield Parameters:

  • product (Hash)


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/#{}/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.

Parameters:

  • product_input (Hash)

    offerId/contentLanguage/feedLabel/productAttributes/customAttributes

  • data_source (String)

    data source resource name

Returns:

  • (Hash)

    the inserted product input



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/#{}/productInputs:insert?dataSource=#{data_source}", product_input)
end

#insert_promotion(promotion, data_source:) ⇒ Hash

Upsert one promotion into a promotions data source.

Parameters:

  • promotion (Hash)

    promotionId/contentLanguage/targetCountry/redemptionChannel/attributes

  • data_source (String)

    data source resource name

Returns:

  • (Hash)

    the inserted promotion



158
159
160
# File 'app/services/feed/google/merchant_api/client.rb', line 158

def insert_promotion(promotion, data_source:)
  post("/promotions/v1/#{}/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).

Parameters:

  • feed_label (String)

    e.g. 'US' / 'CA'

  • content_language (String) (defaults to: 'en')

Returns:

  • (String)

    the data source resource name

Raises:



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).

Parameters:

  • target_country (String)

    e.g. 'US' / 'CA'

  • content_language (String) (defaults to: 'en')

Returns:

  • (String)

    the data source resource name

Raises:

  • (ApiError)

    if no promotions data source exists for the country



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).

Parameters:

  • query (String)

    a Report Language SELECT, e.g. against product_view

  • page_size (Integer) (defaults to: 1000)

Returns:

  • (Array<Hash>)

    each entry is one results[].productView



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/#{}/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