Current section

Files

Jump to
wallet_passes lib wallet_passes google api.ex
Raw

lib/wallet_passes/google/api.ex

defmodule WalletPasses.Google.Api do
@moduledoc """
Google Wallet API client for wallet passes.
Uses the Google Wallet REST API to create, update, and manage
pass objects and classes across all supported types (event tickets,
boarding passes, loyalty cards, offers, and generic passes).
"""
alias WalletPasses.Config
alias WalletPasses.Google.Visual
alias WalletPasses.PassData
alias WalletPasses.PassType
alias WalletPasses.TokenCache
@scope "https://www.googleapis.com/auth/wallet_object.issuer"
# -- Pass Object Building --
@doc """
Builds a Google Wallet pass object map from PassData and Visual.
The object type is determined by `pass_data.pass_type`.
"""
def build_pass_object(%PassData{} = pass_data, %Visual{} = visual) do
issuer_id = Config.google_issuer_id()
object_id = "#{issuer_id}.#{pass_data.serial_number}"
text_modules =
(pass_data.secondary_fields ++ pass_data.auxiliary_fields ++ pass_data.back_fields)
|> Enum.map(fn {key, label, value} ->
%{"id" => key, "header" => label, "body" => value}
end)
obj =
%{
"id" => object_id,
"classId" =>
"#{issuer_id}.#{issuer_id}_#{PassType.google_class_suffix(pass_data.pass_type)}",
"state" => "ACTIVE",
"barcode" => %{
"type" => "QR_CODE",
"value" => PassData.barcode_value(pass_data),
},
"textModulesData" => text_modules,
}
|> put_type_fields(pass_data)
obj =
if visual.background_color do
Map.put(obj, "hexBackgroundColor", visual.background_color)
else
obj
end
obj =
if visual.logo_uri do
Map.put(obj, "logo", %{
"sourceUri" => %{"uri" => visual.logo_uri},
"contentDescription" => %{
"defaultValue" => %{"language" => "en-US", "value" => "Logo"},
},
})
else
obj
end
obj =
if visual.hero_image_uri do
Map.put(obj, "heroImage", %{
"sourceUri" => %{"uri" => visual.hero_image_uri},
"contentDescription" => %{
"defaultValue" => %{"language" => "en-US", "value" => "Hero image"},
},
})
else
obj
end
obj =
if visual.image_modules != [] do
image_module_data =
Enum.map(visual.image_modules, fn {uri, description} ->
%{
"mainImage" => %{
"sourceUri" => %{"uri" => uri},
"contentDescription" => %{
"defaultValue" => %{"language" => "en-US", "value" => description},
},
},
}
end)
Map.put(obj, "imageModulesData", image_module_data)
else
obj
end
obj =
if pass_data.nfc_message do
Map.put(obj, "smartTapRedemptionValue", pass_data.nfc_message)
else
obj
end
obj
end
defp put_type_fields(obj, %PassData{pass_type: :event_ticket} = pass_data) do
maybe_put(obj, "ticketHolderName", pass_data.holder_name)
end
defp put_type_fields(obj, %PassData{pass_type: :boarding_pass} = pass_data) do
maybe_put(obj, "passengerName", pass_data.holder_name)
end
defp put_type_fields(obj, %PassData{pass_type: :store_card} = pass_data) do
maybe_put(obj, "accountName", pass_data.holder_name)
end
defp put_type_fields(obj, %PassData{pass_type: :coupon}), do: obj
defp put_type_fields(obj, %PassData{pass_type: :generic} = pass_data) do
if pass_data.holder_name do
Map.put(obj, "header", %{
"defaultValue" => %{"language" => "en-US", "value" => pass_data.holder_name},
})
else
obj
end
end
defp put_class_type_fields(obj, :event_ticket, class_config) do
Map.put(obj, "eventName", localized_string(class_config.event_name))
end
defp put_class_type_fields(obj, :boarding_pass, class_config) do
Map.put(obj, "localizedIssuerName", localized_string(class_config.event_name))
end
defp put_class_type_fields(obj, :store_card, class_config) do
Map.put(obj, "programName", class_config.event_name)
end
defp put_class_type_fields(obj, :coupon, class_config) do
Map.put(obj, "title", class_config.event_name)
end
defp put_class_type_fields(obj, :generic, _class_config), do: obj
@doc """
Builds a Google Wallet class object map from configuration.
## Options
* `:id` - Class ID suffix (required)
* `:issuer_name` - Issuer name (required)
* `:event_name` - Title/name for the class (required). Maps to type-specific field:
`eventName` for event tickets, `programName` for loyalty, `title` for offers, etc.
* `:pass_type` - Pass type atom (optional, defaults to `:event_ticket`)
* `:start_date` - Start date as ISO 8601 string (optional)
* `:end_date` - End date as ISO 8601 string (optional)
* `:location_name` - Venue name (optional)
* `:location_address` - Venue address (optional)
* `:enable_smart_tap` - Enable Smart Tap / NFC for this class (optional, boolean)
* `:redemption_issuers` - List of redemption issuer IDs authorized for Smart Tap (optional)
* `:latitude` - Venue latitude (optional)
* `:longitude` - Venue longitude (optional)
* `:logo_uri` - Logo image URI (optional)
"""
def build_class_object(class_config) when is_map(class_config) do
issuer_id = Config.google_issuer_id()
class_id = "#{issuer_id}.#{class_config.id}"
pass_type = class_config[:pass_type] || :event_ticket
obj =
%{
"id" => class_id,
"issuerName" => class_config.issuer_name,
"reviewStatus" => "UNDER_REVIEW",
"multipleDevicesAndHoldersAllowedStatus" => "MULTIPLE_HOLDERS",
}
|> put_class_type_fields(pass_type, class_config)
obj =
if class_config[:start_date] || class_config[:end_date] do
date_time =
%{}
|> maybe_put("start", class_config[:start_date])
|> maybe_put("end", class_config[:end_date])
Map.put(obj, "dateTime", date_time)
else
obj
end
obj =
if class_config[:location_name] || class_config[:location_address] do
venue =
%{}
|> maybe_put("name", localized_string(class_config[:location_name]))
|> maybe_put("address", localized_string(class_config[:location_address]))
Map.put(obj, "venue", venue)
else
obj
end
obj =
if is_number(class_config[:latitude]) and is_number(class_config[:longitude]) do
location = %{
"latitude" => class_config[:latitude],
"longitude" => class_config[:longitude],
}
Map.put(obj, "locations", [location])
else
obj
end
obj =
if class_config[:logo_uri] do
Map.put(obj, "logo", %{
"sourceUri" => %{"uri" => class_config.logo_uri},
"contentDescription" => %{
"defaultValue" => %{"language" => "en-US", "value" => "Logo"},
},
})
else
obj
end
obj =
if class_config[:enable_smart_tap] do
obj
|> Map.put("enableSmartTap", true)
|> maybe_put("redemptionIssuers", class_config[:redemption_issuers])
else
obj
end
obj
end
# -- API Operations --
@doc """
Creates or updates a pass class on Google's servers.
Attempts to update first; falls back to insert on 404.
Pass type defaults to `:event_ticket` when not specified.
"""
def create_or_update_class(class_config, pass_type \\ :event_ticket) do
class_object = build_class_object(class_config)
class_id = class_object["id"]
class_type = PassType.google_class_type(pass_type)
with {:ok, token} <- get_access_token() do
case api_put(
"#{Config.google_api_base_url()}/#{class_type}/#{class_id}",
class_object,
token
) do
{:ok, %{status: status} = resp} when status in 200..299 ->
{:ok, resp.body}
{:ok, %{status: 404}} ->
case api_post("#{Config.google_api_base_url()}/#{class_type}", class_object, token) do
{:ok, %{status: status} = resp} when status in 200..299 ->
{:ok, resp.body}
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Creates a pass object on Google's servers.
If the object already exists (409), updates it instead.
The object type is determined by `pass_data.pass_type`.
Returns `{:ok, object_id}` on success.
"""
def create_object(%PassData{} = pass_data, %Visual{} = visual) do
pass_object = build_pass_object(pass_data, visual)
object_id = pass_object["id"]
object_type = PassType.google_object_type(pass_data.pass_type)
with {:ok, token} <- get_access_token() do
case api_post("#{Config.google_api_base_url()}/#{object_type}", pass_object, token) do
{:ok, %{status: status}} when status in 200..299 ->
{:ok, object_id}
{:ok, %{status: 409}} ->
case api_put(
"#{Config.google_api_base_url()}/#{object_type}/#{object_id}",
pass_object,
token
) do
{:ok, %{status: status}} when status in 200..299 ->
{:ok, object_id}
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
@doc """
Updates an existing pass object on Google's servers.
The object type is determined by `pass_data.pass_type`.
Returns `{:ok, object_id}` on success.
"""
def update_object(%PassData{} = pass_data, %Visual{} = visual, object_id)
when is_binary(object_id) do
pass_object =
build_pass_object(pass_data, visual)
|> Map.put("id", object_id)
object_type = PassType.google_object_type(pass_data.pass_type)
with {:ok, token} <- get_access_token() do
case api_patch(
"#{Config.google_api_base_url()}/#{object_type}/#{object_id}",
pass_object,
token
) do
{:ok, %{status: status}} when status in 200..299 ->
{:ok, object_id}
{:ok, %{status: status, body: body}} ->
{:error, {status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
# -- OAuth Token --
@doc """
Gets a cached Google OAuth2 access token, refreshing if expired.
"""
def get_access_token do
TokenCache.fetch(:google_access_token, fn ->
with {:ok, %{"client_email" => email, "private_key" => private_key_pem}} <-
load_credentials() do
exchange_jwt_for_token(email, private_key_pem)
end
end)
end
defp exchange_jwt_for_token(email, private_key_pem) do
now = DateTime.utc_now() |> DateTime.to_unix()
claims = %{
"iss" => email,
"scope" => @scope,
"aud" => Config.google_token_url(),
"iat" => now,
"exp" => now + 3600,
}
signer = Joken.Signer.create("RS256", %{"pem" => private_key_pem})
with {:ok, jwt, _claims} <- Joken.encode_and_sign(claims, signer) do
case Req.post(Config.google_token_url(),
form: [
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion: jwt,
]
) do
{:ok, %{status: 200, body: %{"access_token" => token}}} ->
{:ok, token}
{:ok, %{status: status, body: body}} ->
{:error, {:token_exchange_failed, status, body}}
{:error, reason} ->
{:error, reason}
end
end
end
# -- Credentials --
defp load_credentials do
json_str = Config.google_service_account_json()
json_str =
if File.exists?(json_str) do
File.read!(json_str)
else
json_str
end
Jason.decode(json_str)
rescue
_ -> {:error, :no_google_credentials}
end
# -- HTTP Helpers --
defp api_post(url, body, token) do
Req.post(url,
json: body,
headers: [{"authorization", "Bearer #{token}"}]
)
end
defp api_put(url, body, token) do
Req.put(url,
json: body,
headers: [{"authorization", "Bearer #{token}"}]
)
end
defp api_patch(url, body, token) do
Req.patch(url,
json: body,
headers: [{"authorization", "Bearer #{token}"}]
)
end
# -- Utility --
defp localized_string(nil), do: nil
defp localized_string(value) do
%{"defaultValue" => %{"language" => "en-US", "value" => value}}
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
end