Kemp.rest / Examples / Code / Google Business Profile

KEMP.REST / EXAMPLES / CODE

Google Business Profile on Google Cloud

Written by Kevin Kemp in PHP and Laravel for AnswerPins. Google Cloud OAuth, JSON, and the Business Profile REST APIs. API credentials stay in the environment, not in this code.

View the code

Google Business Profile has no simple key. The listing owner connects a Google account once, through an OAuth client on a Google Cloud project, and AnswerPins then edits the listing the account manages. This service is that connection: sign-in, token refresh, finding the right location, and a single-field update.

Type of code

A Laravel service. Controllers call it. It does not render a page.

Technology

PHP, the Laravel HTTP client, JSON, Google Cloud OAuth (accounts.google.com and oauth2.googleapis.com), and two Business Profile APIs: Account Management and Business Information. The scope is business.manage. The client id and secret come from config. The feature stays off until gbp_enabled is set, which is how the app waits on Google’s access approval.

Timing

What it does

Problem it solved

Listing fixes could be made from the app against the owner’s real Google Business Profile, instead of telling the owner to click through Google’s screens for category, phone, website, and description.

Code

Back to summary · app/Services/GoogleBusinessProfile.php

<?php

namespace App\Services;

use App\Models\Business;
use App\Models\GoogleConnection;
use Illuminate\Support\Facades\Http;
use RuntimeException;

/**
 * The Google Business Profile API: the owner (or a manager) connects their Google account once, then fixes to the
 * listing's main category, website, phone and description are made from AnswerPins. Off until Google approves access
 * (config answerpins.google.gbp_enabled).
 */
class GoogleBusinessProfile
{
    private const AUTH = 'https://accounts.google.com/o/oauth2/v2/auth';

    private const TOKEN = 'https://oauth2.googleapis.com/token';

    private const ACCOUNTS = 'https://mybusinessaccountmanagement.googleapis.com/v1';

    private const INFO = 'https://mybusinessbusinessinformation.googleapis.com/v1';

    /** Listing fields a fix can change directly, and the API field each one maps to. */
    public const FIELDS = ['category' => 'categories', 'website' => 'websiteUri', 'phone' => 'phoneNumbers', 'description' => 'profile'];

    public function enabled(): bool
    {
        return (bool) (config('answerpins.google.gbp_enabled') && config('answerpins.google.oauth_client_id') && config('answerpins.google.oauth_client_secret'));
    }

    public function authUrl(string $state): string
    {
        return self::AUTH.'?'.http_build_query([
            'client_id' => config('answerpins.google.oauth_client_id'),
            'redirect_uri' => route('dashboard.google.callback'),
            'response_type' => 'code',
            'scope' => 'https://www.googleapis.com/auth/business.manage email',
            'access_type' => 'offline',
            'prompt' => 'consent',
            'state' => $state,
        ]);
    }

    /** Swaps the sign-in code for tokens, then finds the business's listing among the ones this Google account manages. */
    public function connect(Business $business, string $code, ?int $userId): GoogleConnection
    {
        $t = Http::asForm()->timeout(20)->post(self::TOKEN, [
            'code' => $code, 'client_id' => config('answerpins.google.oauth_client_id'), 'client_secret' => config('answerpins.google.oauth_client_secret'),
            'redirect_uri' => route('dashboard.google.callback'), 'grant_type' => 'authorization_code',
        ])->throw()->json();

        $conn = GoogleConnection::updateOrCreate(['business_id' => $business->id], [
            'access_token' => $t['access_token'], 'refresh_token' => $t['refresh_token'] ?? $business->googleConnection?->refresh_token,
            'expires_at' => now()->addSeconds((int) ($t['expires_in'] ?? 3600) - 60), 'connected_by' => $userId,
            'google_email' => $this->emailFromIdToken($t['id_token'] ?? null),
        ]);

        [$name, $title] = $this->findLocation($conn, $business);
        $conn->update(['location_name' => $name, 'location_title' => $title]);

        return $conn;
    }

    /** @return array{0: ?string, 1: ?string} the listing whose Google place matches the business, across every account the user manages */
    public function findLocation(GoogleConnection $conn, Business $business): array
    {
        $accounts = $this->get($conn, self::ACCOUNTS.'/accounts')['accounts'] ?? [];
        foreach ($accounts as $account) {
            $page = null;
            do {
                $res = $this->get($conn, self::INFO.'/'.$account['name'].'/locations', array_filter(['readMask' => 'name,title,metadata', 'pageSize' => 100, 'pageToken' => $page]));
                foreach ($res['locations'] ?? [] as $loc) {
                    if ($business->google_place_id && ($loc['metadata']['placeId'] ?? null) === $business->google_place_id) {
                        return [$loc['name'], $loc['title'] ?? null];
                    }
                }
                $page = $res['nextPageToken'] ?? null;
            } while ($page);
        }

        return [null, null];
    }

    /** @return list<array{name: string, displayName: string}> Google's own categories matching a search, e.g. "sailing" */
    public function categories(GoogleConnection $conn, string $search): array
    {
        $res = $this->get($conn, self::INFO.'/categories', ['regionCode' => 'US', 'languageCode' => 'en', 'view' => 'BASIC', 'filter' => 'displayName='.$search, 'pageSize' => 10]);

        return array_map(fn ($c) => ['name' => $c['name'], 'displayName' => $c['displayName']], $res['categories'] ?? []);
    }

    /** Makes one change on the listing. Google may hold it for review before it shows. */
    public function update(GoogleConnection $conn, string $field, string $value): void
    {
        if (! $conn->location_name) {
            throw new RuntimeException("We couldn't find your listing in the Google account you connected.");
        }
        $mask = self::FIELDS[$field] ?? throw new RuntimeException("{$field} can't be changed from here yet.");
        $body = match ($field) {
            'category' => ['categories' => ['primaryCategory' => $this->category($conn, $value)]],
            'website' => ['websiteUri' => $value],
            'phone' => ['phoneNumbers' => ['primaryPhone' => $value]],
            'description' => ['profile' => ['description' => $value]],
        };
        $mask = $field === 'category' ? 'categories.primaryCategory' : ($field === 'phone' ? 'phoneNumbers.primaryPhone' : ($field === 'description' ? 'profile.description' : $mask));

        $this->token($conn);
        Http::withToken($conn->access_token)->timeout(20)
            ->patch(self::INFO.'/'.$conn->location_name.'?'.http_build_query(['updateMask' => $mask]), $body)->throw();
    }

    /** A category by its display name, as Google's {name: "categories/gcid:…"} */
    private function category(GoogleConnection $conn, string $displayName): array
    {
        foreach ($this->categories($conn, $displayName) as $c) {
            if (mb_strtolower($c['displayName']) === mb_strtolower($displayName)) {
                return ['name' => $c['name']];
            }
        }
        throw new RuntimeException("\"{$displayName}\" isn't one of Google's categories. Pick one from the list.");
    }

    private function get(GoogleConnection $conn, string $url, array $query = []): array
    {
        $this->token($conn);

        return Http::withToken($conn->access_token)->timeout(20)->get($url, $query)->throw()->json() ?? [];
    }

    /** Refreshes the access token when it has run out. */
    private function token(GoogleConnection $conn): void
    {
        if ($conn->expires_at && $conn->expires_at->isFuture()) {
            return;
        }
        if (! $conn->refresh_token) {
            throw new RuntimeException('Your Google connection has expired. Connect your Google Business Profile again.');
        }
        $t = Http::asForm()->timeout(20)->post(self::TOKEN, [
            'client_id' => config('answerpins.google.oauth_client_id'), 'client_secret' => config('answerpins.google.oauth_client_secret'),
            'refresh_token' => $conn->refresh_token, 'grant_type' => 'refresh_token',
        ])->throw()->json();
        $conn->update(['access_token' => $t['access_token'], 'expires_at' => now()->addSeconds((int) ($t['expires_in'] ?? 3600) - 60)]);
    }

    private function emailFromIdToken(?string $jwt): ?string
    {
        $part = $jwt ? explode('.', $jwt)[1] ?? null : null;

        return $part ? (json_decode(base64_decode(strtr($part, '-_', '+/')), true)['email'] ?? null) : null;
    }
}