こんにちは。SI部のr_maedaです。
今回は、AndroidアプリケーションのバックエンドシステムをRuby on Railsで開発している方に向けた話です。
Androidアプリケーション上で発生した購入をバックエンドのシステムで処理するためには、Google Play Developer APIのSubscriptions and In-App Purchases APIを利用することになります。
例えば、定期購入(=サブスクリプション)のメタデータを取得するためには、 purchases.subscriptionsv2.get APIを利用します。
RubyからこのAPIを利用する場合、Googleが公式に開発/提供してくれているgemである google-apis-androidpublisher_v3 を採用することをまず考える人が多いでしょう。
このgemはGoogle Play Developer APIの変更に合わせてリリースされているので、最新版のこのgemを使えば、公開されているすべてのAPIを利用できます。
ただしGoogle Play Developer APIには、EAP(Early Access Program。限定された開発者にのみ仕様が公開されている機能)も存在し、EAPの対象機能についてはオープンソースである google-apis-androidpublisher_v3 gemからは利用できません。
例えば、 2025年11月中旬に、Google Play Developer APIには「有効期限切れ後の再度定期購入」のサポートが追加されましたが、この機能も以前はEAPの対象でした。
今回の記事では、Google Play Developer APIの「有効期限切れ後の再度定期購入」サポートがEAPの対象だった頃に、google-apis-androidpublisher_v3 gemからこの機能を利用するために作成したモンキーパッチの内容と、その作成手順について解説します。
この記事では google-apis-androidpublisher_v3 gemのv0.78.0を対象にモンキーパッチを作成しています。これはRuby 2.7, 3.0で動作する最終バージョンです。
そして、Google Play Developer APIの「有効期限切れ後の再度定期購入」サポートについてもここで軽く説明させてください。
これは「purchases.subscriptionsv2.get APIのレスポンスに追加される outOfAppPurchaseContext フィールドの値を使うことで、前回の定期購入を特定できるようになる」といったものです。バックエンドのシステムでは、この情報を使って「前回の定期購入が紐づいていたアカウント」を特定し、そのアカウントに今回の定期購入を紐づけられるようになります。
今回作成したモンキーパッチでは、この outOfAppPurchaseContext の内容をRubyコード上から参照できるようにします。
// purchases.subscriptionsv2.get APIのレスポンスフォーマット(一部省略)
{
// 既存フィールド
"lineItems": [
{
"productId": string,
...
},
],
"pausedStateContext": {
"autoResumeTime": string
},
"subscriptionState": string,
...
// 追加フィールド
"outOfAppPurchaseContext": {
"expiredExternalAccountIdentifiers": {
"externalAccountId": string,
"obfuscatedExternalAccountId": string,
"obfuscatedExternalProfileId": string
},
"expiredPurchaseToken": string
}
}モンキーパッチを作成するためには、まずパッチ対象のコードについて知っている必要があります。gemのコードを確認しながらパッチの作成方針を検討しましょう。
purchases.subscriptionsv2.get APIに関係するコードは、GitHubリポジトリ内では以下のディレクトリに存在します。
generated/
google-apis-androidpublisher_v3/
lib/
google/
apis/
androidpublisher_v3/
classes.rb
gem_version.rb
representations.rb
service.rb
まずは purchases.subscriptionsv2.get APIを呼ぶ際に使うメソッドを確認してみましょう。メソッドは service.rb に定義されています。
module Google
module Apis
module AndroidpublisherV3
class AndroidPublisherService < Google::Apis::Core::BaseService
def get_purchase_subscriptionsv2(package_name, token, fields: nil, quota_user: nil, options: nil, &block)
command = make_simple_command(:get, 'androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}', options)
command.response_representation = Google::Apis::AndroidpublisherV3::SubscriptionPurchaseV2::Representation
command.response_class = Google::Apis::AndroidpublisherV3::SubscriptionPurchaseV2
# 以下省略
end
end
end
end
endこのメソッドの2行目には command.response_representation= に SubscriptionPurchaseV2::Representation クラスを渡している行がありますね。また3行目では command.response_class= に SubscriptionPurchaseV2 クラスを渡しているようです。
response_class= はその名前から「 command の実行結果を格納するクラス= get_purchase_subscriptionsv2 メソッドの戻り値を表現するクラスの指定」かなと想像できますが、どうでしょうか。それぞれのクラスの定義を見てみましょう。
SubscriptionPurchaseV2 クラス、SubscriptionPurchaseV2::Representation クラスの定義は、それぞれ classes.rb と reperesentations.rb に存在します。
SubscriptionPurchaseV2 クラス
SubscriptionPurchaseV2::Representation クラス
これらのクラス定義の一部を抜き出したものが以下です。
module Google
module Apis
module AndroidpublisherV3
# classes.rb
class SubscriptionPurchaseV2
include Google::Apis::Core::Hashable
attr_accessor :line_items
attr_accessor :paused_state_context
attr_accessor :subscription_state
def initialize(**args)
update!(**args)
end
def update!(**args)
@line_items = args[:line_items] if args.key?(:line_items)
@paused_state_context = args[:paused_state_context] if args.key?(:paused_state_context)
@subscription_state= args[:subscription_state] if args.key?(:subscription_state)
end
end
# representations.rb: 前半
class SubscriptionPurchaseV2
class Representation < Google::Apis::Core::JsonRepresentation; end
include Google::Apis::Core::JsonObjectSupport
end
# representations.rb: 後半
class SubscriptionPurchaseV2
class Representation < Google::Apis::Core::JsonRepresentation
collection :line_items, as: 'lineItems', class: Google::Apis::AndroidpublisherV3::SubscriptionPurchaseLineItem, decorator: Google::Apis::AndroidpublisherV3::SubscriptionPurchaseLineItem::Representation
property :paused_state_context, as: 'pausedStateContext', class: Google::Apis::AndroidpublisherV3::PausedStateContext, decorator: Google::Apis::AndroidpublisherV3::PausedStateContext::Representation
property :subscription_state, as: 'subscriptionState'
end
end
end
end
endこれらのクラスを見ると、以下のような方針で実装されていることが予想できます。
Representation も定義されているattr_accessor をつかう
attr_accessor では表現できない「各属性の型」や、「対応するJSONオブジェクトのフィールドの名前」は、各RESTリソースに対応するクラスの内部クラス Representation に定義するこれから作成するモンキーパッチについても、この方針を踏襲するのが良さそうです。
パッチ対象のコードを確認した結果を踏まえ、今回は以下の方針でモンキーパッチを作成することにしました。
OutOfAppPurchaseContext
SubscriptionPurchaseV2 クラスに倣って #initialize と #update! を定義OutOfAppPurchaseContext::Representation
OutOfAppPurchaseContext クラスの属性を対応させる property を定義SubscriptionPurchaseV2 クラス
#update! に、追加定義した属性の内容を更新する行を追加SubscriptionPurchaseV2::Representation クラス
SubscriptionPurchaseV2 クラスに追加した属性を対応させる property を追加AndroidPublisherService クラス
まずは、APIレスポンスの outOfAppPurchaseContext フィールドから取得できるオブジェクトに対応するクラスを定義します。クラス名は既存のクラスと重複しなければ何でもよいのですが、ここでは最新版の google-apis-androidpublisher_v3 gem が提供するクラスと同じ名前 (OutOfAppPurchaseContext) とします。
module Google
module Apis
module AndroidpublisherV3
class OutOfAppPurchaseContext
include Google::Apis::Core::Hashable
# NOTE: ExternalAccountIdentifiers classは他でも使われているため、v0.78.0にも定義されている
# @return [Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers]
attr_accessor :expired_external_account_identifiers
# @return [String]
attr_accessor :expired_purchase_token
def initialize(**args)
update!(**args)
end
# Update properties of this object
def update!(**args)
@expired_external_account_identifiers = args[:expired_external_account_identifiers] if args.key?(:expired_external_account_identifiers)
@expired_purchase_token = args[:expired_purchase_token] if args.key?(:expired_purchase_token)
end
end
end
end
end次に、APIレスポンスの outOfAppPurchaseContext フィールドから取得できるオブジェクトを、Rubyオブジェクトで表現する際のマッピングを、 OutOfAppPurchaseContext::Representation クラスを作って定義します。
outOfAppPurchaseContext.expiredExternalAccountIdentifiers にはオブジェクトを期待しているので as:/class:/decorator: を、outOfAppPurchaseContext.expiredPurchaseToken には文字列を期待しているので as: のみを指定します。
module Google
module Apis
module AndroidpublisherV3
class OutOfAppPurchaseContext
class Representation < Google::Apis::Core::JsonRepresentation
property :expired_external_account_identifiers,
as: 'expiredExternalAccountIdentifiers',
class: Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers,
decorator: Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers::Representation
property :expired_purchase_token, as: 'expiredPurchaseToken'
end
include Google::Apis::Core::JsonObjectSupport
end
end
end
end続いて、SubscriptionPurchaseV2 クラスに属性を追加し、 #update! を修正します。このクラスはすでにgem内で定義されているクラスであるため、継承やmixinを改めて書く必要はありません。
ただし、#update! の再定義には注意が必要です。SubscriptionPurchaseV2 クラスに直接メソッドを再定義してしまうと、gem内で定義されていた #update! を上書きしてしまうことになります。
module Google
module Apis
module AndroidpublisherV3
class SubscriptionPurchaseV2
attr_accessor :out_of_app_purchase_context
# NG: gem 内で定義されている #update! は呼ばれなくなる
def update!(**args)
@out_of_app_purchase_context = args[:out_of_app_purchase_context] if args.key?(:out_of_app_purchase_context)
end
end
end
end
endgem内に定義されているメソッドをコピー&ペーストしてきた上で新しい属性の操作を追加すれば、メソッドの挙動については維持できますが、この方法では「gemに定義された #update! が今後変更された場合、その変更に追従できない」など、将来的に不具合の原因となる可能性が考えられます。
したがって、以下の例のように Module#prepend や alias を使い、gem内に定義されている #update! を拡張する方法を採用するのがよいでしょう。
# Module.prepend版
module Google
module Apis
module AndroidpublisherV3
class SubscriptionPurchaseV2
prepend Module.new do
attr_accessor :out_of_app_purchase_context
def update!(**args)
super
@out_of_app_purchase_context = args[:out_of_app_purchase_context] if args.key?(:out_of_app_purchase_context)
end
end
end
end
end
end# alias版
module Google
module Apis
module AndroidpublisherV3
class SubscriptionPurchaseV2
attr_accessor :out_of_app_purchase_context
alias _original_update! update!
def update!(**args)
_original_update!(**args)
@out_of_app_purchase_context = args[:out_of_app_purchase_context] if args.key?(:out_of_app_purchase_context)
end
end
end
end
endSubscriptionPurchaseV2::Representation にも忘れずにマッピングを追加してください。このクラスもすでにgem内で定義されているクラスであるため、改めての継承やmixinは不要です。
module Google
module Apis
module AndroidpublisherV3
class SubscriptionPurchaseV2
class Representation
property :out_of_app_purchase_context,
as: 'outOfAppPurchaseContext',
class: Google::Apis::AndroidpublisherV3::OutOfAppPurchaseContext,
decorator: Google::Apis::AndroidpublisherV3::OutOfAppPurchaseContext::Representation
end
end
end
end
endここまでのコードを結合し、スクリプト先頭にgemのコードを require する行を追加したら、完成です。
Railsプロジェクトの場合、このファイルを config/initializers/ 以下に置けば、アプリケーションの起動時にモンキーパッチが適用されます。
# config/initializers/google-apis-androidpublisher_v3.rb
# ここにrequireを書くことで、gem -> patchの順でコードが読み込まれることを強制する
require 'google-apis-androidpublisher_v3'
module Google
module Apis
module AndroidpublisherV3
class OutOfAppPurchaseContext
# 新規クラス: outOfAppPurchaseContextフィールドに設定された値を保持する
class Representation < Google::Apis::Core::JsonRepresentation
property :expired_external_account_identifiers,
as: 'expiredExternalAccountIdentifiers',
class: Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers,
decorator: Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers::Representation
property :expired_purchase_token, as: 'expiredPurchaseToken'
end
include Google::Apis::Core::Hashable
include Google::Apis::Core::JsonObjectSupport
# @return [Google::Apis::AndroidpublisherV3::ExternalAccountIdentifiers]
attr_accessor :expired_external_account_identifiers
# @return [String]
attr_accessor :expired_purchase_token
def initialize(**args)
update!(**args)
end
# Update properties of this object
def update!(**args)
@expired_external_account_identifiers = args[:expired_external_account_identifiers] if args.key?(:expired_external_account_identifiers)
@expired_purchase_token = args[:expired_purchase_token] if args.key?(:expired_purchase_token)
end
end
# 既存クラス: outOfAppPurchaseContextフィールドのハンドリングを追加
class SubscriptionPurchaseV2
class Representation
property :out_of_app_purchase_context,
as: 'outOfAppPurchaseContext',
class: Google::Apis::AndroidpublisherV3::OutOfAppPurchaseContext,
decorator: Google::Apis::AndroidpublisherV3::OutOfAppPurchaseContext::Representation
end
prepend Module.new do
# @return [Google::Apis::AndroidpublisherV3::OutOfAppPurchaseContext]
attr_accessor :out_of_app_purchase_context
def update!(**args)
super
@out_of_app_purchase_context = args[:out_of_app_purchase_context] if args.key?(:out_of_app_purchase_context)
end
end
end
end
end
end以下のようなテストコードを作成し、モンキーパッチを適用したあとのコードの挙動が、期待どおりに変化していることを確認しました。
# spec/initializers/google-apis-androidpublisher_v3_spec.rb
RSpec.describe Google::Apis::AndroidpublisherV3::AndroidPublisherService do
describe '#get_purchase_subscriptionsv2' do
subject do
described_class.new.get_purchase_subscriptionsv2(package_name, purchase_token)
end
let!(:package_name) { 'package_name' }
let!(:purchase_token) { SecureRandom.uuid }
let!(:subscription_state) { 'SUBSCRIPTION_STATE_ACTIVE' }
let!(:expired_purchase_token) { SecureRandom.uuid }
before do
request_url =
"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/#{package_name}/purchases/subscriptionsv2/tokens/#{purchase_token}"
response_body = {
'subscriptionState' => subscription_state, # 既存フィールド
'outOfAppPurchaseContext' => { # 追加フィールド
'expiredPurchaseToken' => expired_purchase_token
}
}
WebMock::API.stub_request(:get, request_url).to_return_json(body: response_body)
end
it '既存フィールドの値と、モンキーパッチで対応したフィールドの両方の値が取得できること' do
expect(subject).to have_attributes(
subscription_state: subscription_state,
out_of_app_purchase_context: have_attributes(
expired_purchase_token: expired_purchase_token
)
)
end
end
end今回の記事では、Google API Ruby Clientの1つである google-apis-androidpublisher_v3 gemがサポート対象外としていたGoogle Play Developer APIの非公開機能を、当該gemから利用するためのモンキーパッチを作成する方法について解説しました。
このモンキーパッチを作成し始めた当初は、「有効期限切れ後の再度定期購入」のサポートはEAPとして提供されていたため、モンキーパッチを書く必要がありましたが、 google-apis-androidpublisher_v3 gemのv0.89.0にて正式に機能がサポートされたため、現在はこのようなモンキーパッチを作成する必要はなくなっています。
今までは「Google API Ruby Clientが何をしているのか」をあまり考えずにただgemを使うだけでしたが、今回のモンキーパッチ作成を通じて、gemの内部構造を理解したことは、よい経験になったのかなと思っています。楽しかったです。