From f00d0b170a58eca9eb4471759d8e2186f1c05b0b Mon Sep 17 00:00:00 2001
From: Sebastiaan <45851377+Kyzegs@users.noreply.github.com>
Date: Fri, 5 Jun 2026 10:52:09 +0200
Subject: [PATCH] feat: add Places API (New) support with session tokens
Migrate GoogleService to the Places API (New) endpoints while keeping the
legacy options working. Adds session token generation for billing,
locationBias/locationRestriction, includedRegionCodes, languageCode and
includedPrimaryTypes, plus query-prediction results. Legacy `components`
and `queryTypes` are parsed into their new-API equivalents.
---
README.md | 122 ++++++---
example/src/App.tsx | 8 +-
package.json | 1 -
src/GoogleAutocomplete.tsx | 292 ++++++++++++++++-----
src/__tests__/index.test.tsx | 158 +++++++++++-
src/index.tsx | 14 +
src/services/google.service.ts | 456 ++++++++++++++++++++++++---------
yarn.lock | 41 ---
8 files changed, 822 insertions(+), 270 deletions(-)
diff --git a/README.md b/README.md
index fbd08bb..ec1c52d 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,11 @@ yarn add @appandflow/react-native-google-autocomplete
npm i @appandflow/react-native-google-autocomplete
```
+> **Uses Places API (New).** This library calls the new
+> [`places.googleapis.com/v1`](https://developers.google.com/maps/documentation/places/web-service/place-autocomplete)
+> Autocomplete and Place Details endpoints. You must enable **Places API (New)**
+> in your Google Cloud project — the legacy "Places API" is not enough.
+
## Usage
The `useGoogleAutocomplete` hook takes 2 arguments
@@ -24,21 +29,40 @@ The `useGoogleAutocomplete` hook takes 2 arguments
# Config object
-| Property | Description |
-| --------------- | ------------------------------------------------------------------------------------------------------ |
-| debounce | optional - default 300 |
-| debounceOptions | optional - Configuration options for debounce behavior. |
-| language | optional - default 'en' |
-| queryTypes | optional - default address - https://developers.google.com/places/web-service/autocomplete#place_types |
-| minLength | optional - default 2 - this is the min length of the term search before start |
-| components | optional - A grouping of places to which you would like to restrict your results |
-| radius | optional - The distance (in meters) within which to return place results |
-| lat | optional - The latitude. If provided, lng is required |
-| lng | optional - The longitude. If provided, lat is required |
-| strictBounds | optional - Returns only places that are strictly within the region defined by location and radius. |
-| origin | optional - The origin point as { lat, lng } from which to calculate straight-line distance to the destinations (returned as distance_meters) |
-| proxyUrl | optional - This is required if you want to use the hook in a Web based platform. Since we dont use the Google SDK, the http call will fail because of issues related to CORS unless a proxyUrl is provided |
-| headers | optional - Custom headers to include in the Google Places API requests. Useful for passing platform-specific API restrictions such as `X-Android-Package`, `X-Android-Cert`, or `X-Ios-Bundle-Identifier`. |
+| Property | Description |
+| --------------------- | ------------------------------------------------------------------------------------------------------------ |
+| debounce | optional - default 300 |
+| debounceOptions | optional - Configuration options for debounce behavior. |
+| minLength | optional - default 2 - min length of the term before search starts |
+| languageCode | optional - default 'en' - request language, e.g. `'nl'` |
+| regionCode | optional - CLDR region code used to format results, e.g. `'nl'` |
+| includedRegionCodes | optional - restrict results to up to 15 country codes, e.g. `['nl', 'be']` |
+| includedPrimaryTypes | optional - restrict to [place types](https://developers.google.com/maps/documentation/places/web-service/place-types), e.g. `['establishment']` for venues/POIs |
+| locationBias | optional - bias results toward an area (soft). `{ circle: { center: { latitude, longitude }, radius } }` |
+| locationRestriction | optional - restrict results to an area (hard). Same shape as `locationBias` |
+| includeQueryPredictions | optional - also return query predictions (carry `isQueryPrediction: true`) |
+| origin | optional - `{ lat, lng }` from which to calculate straight-line distance (returned as `distance_meters`) |
+| sessionToken | optional - provide your own [session token](https://developers.google.com/maps/documentation/places/web-service/place-session-tokens). By default one is generated and rotated after each `searchDetails`/`clearSearch` |
+| autocompleteFieldMask | optional - override the `X-Goog-FieldMask` for autocomplete requests |
+| placeDetailsFieldMask | optional - override the `X-Goog-FieldMask` for place details requests |
+| proxyUrl | optional - required for Web platforms to avoid CORS issues |
+| headers | optional - Custom headers, e.g. `X-Android-Package`, `X-Android-Cert`, `X-Ios-Bundle-Identifier` |
+
+### Deprecated (legacy) options
+
+These still work and are mapped to the new API, but prefer the options above:
+
+| Legacy | Maps to |
+| ------------------ | ---------------------------------------- |
+| `language` | `languageCode` |
+| `components` | `includedRegionCodes` (parses `country:xx`) |
+| `queryTypes` | `includedPrimaryTypes` (only `establishment` maps; other values = no type filter) |
+| `lat` / `lng` / `radius` | `locationBias.circle` |
+| `strictBounds` | turns the lat/lng/radius into `locationRestriction` |
+
+> **Looking up venues** like _Johan Cruijff ArenA_, _Ziggo Dome_ or _Ahoy_?
+> Set `includedPrimaryTypes: ['establishment']` (optionally with
+> `includedRegionCodes: ['nl']`).
# Exposed properties
@@ -59,7 +83,8 @@ Clicking on a result logs the details of that result.
```ts
const { locationResults, setTerm, clearSearch, searchDetails, term } =
useGoogleAutocomplete(API_KEY, {
- language: 'en',
+ languageCode: 'en',
+ includedPrimaryTypes: ['establishment'],
debounce: 300,
});
@@ -86,56 +111,51 @@ Clicking on a result logs the details of that result.
## Results
-`locationResults` returns the following. The maximum result set by Google is 5 locations by query.
+`locationResults` returns the following. The new-API prediction is normalized to
+the field names below (backward compatible with previous versions); the raw
+prediction is available on `_raw`.
-```js
+```ts
export interface GoogleLocationResult {
description: string;
- id: string;
- matched_substrings: Array<{
- length: number;
- offset: number;
- }>;
place_id: string;
- reference: string;
structured_formatting: {
main_text: string;
secondary_text: string;
main_text_matched_substrings: Array<{
length: number;
+ offset?: number;
}>;
};
- terms: Array<{
- offset: number;
- value: string;
- }>;
types: string[];
distance_meters?: number; // Present when origin parameter is provided
+ isQueryPrediction?: boolean; // true for query predictions
+ _raw: object; // raw Places API (New) prediction
}
```
-When calling the searchDetails this is what you get
+When calling `searchDetails` this is what you get. Place Details (New) requires a
+field mask — override `placeDetailsFieldMask` to request more/fewer fields.
-```js
+```ts
export interface GoogleLocationDetailResult {
- adr_address: string;
- formatted_address: string;
- icon: string;
id: string;
- name: string;
place_id: string;
- scope: string;
- reference: string;
- url: string;
- utc_offset: number;
+ name: string;
+ formatted_address: string;
+ adr_address: string;
vicinity: string;
+ url: string; // googleMapsUri
+ website: string; // websiteUri
+ utc_offset: number; // utcOffsetMinutes
types: string[];
+ primary_type?: string;
geometry: {
location: {
lat: number;
lng: number;
};
- viewport: {
+ viewport?: {
[type: string]: {
lat: number;
lng: number;
@@ -147,6 +167,7 @@ export interface GoogleLocationDetailResult {
short_name: string;
types: string[];
}>;
+ _raw: object; // raw Places API (New) place
}
```
@@ -184,4 +205,25 @@ const { locationResults, setTerm } = useGoogleAutocomplete(API_KEY, {
## Restrict by country
-If you want to restrict the search by country you can add this as a props `components="country:ca"`. This here would example restrict it to Canada only.
+Pass `includedRegionCodes` with up to 15 CLDR country codes:
+
+```ts
+useGoogleAutocomplete(API_KEY, {
+ includedRegionCodes: ['ca'], // Canada only
+});
+```
+
+## Bias / restrict by location
+
+```ts
+useGoogleAutocomplete(API_KEY, {
+ // soft bias around Amsterdam (50km)
+ locationBias: {
+ circle: {
+ center: { latitude: 52.3676, longitude: 4.9041 },
+ radius: 50000,
+ },
+ },
+ // or a hard restriction with `locationRestriction` (same shape)
+});
+```
diff --git a/example/src/App.tsx b/example/src/App.tsx
index 6f01ab5..b4550dd 100644
--- a/example/src/App.tsx
+++ b/example/src/App.tsx
@@ -16,8 +16,12 @@ export default function App() {
const { setTerm, locationResults, isSearching } = useGoogleAutocomplete(
API_KEY,
{
- language: 'en',
+ languageCode: 'en',
minLength: 3,
+ // Return venues / businesses / POIs (e.g. Johan Cruijff ArenA, Ziggo Dome)
+ includedPrimaryTypes: ['establishment'],
+ // Restrict to one or more countries
+ // includedRegionCodes: ['nl'],
proxyUrl: isWeb ? 'https://cors-anywhere.herokuapp.com/' : undefined,
// Example: Using origin to get distance from Times Square, NYC
// Uncomment to see distance_meters in results
@@ -40,7 +44,7 @@ export default function App() {
Searching...
)}
{locationResults.map((location) => (
-
+
{location.structured_formatting.main_text}
{location.distance_meters && (
diff --git a/package.json b/package.json
index 99a6daa..f7eea04 100644
--- a/package.json
+++ b/package.json
@@ -182,7 +182,6 @@
"version": "0.38.1"
},
"dependencies": {
- "query-string": "7.1.3",
"use-debounce": "^10.0.1"
}
}
diff --git a/src/GoogleAutocomplete.tsx b/src/GoogleAutocomplete.tsx
index 045363a..e39f8ad 100644
--- a/src/GoogleAutocomplete.tsx
+++ b/src/GoogleAutocomplete.tsx
@@ -1,7 +1,10 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import {
GoogleService,
+ type AutocompleteRequest,
type GoogleLocationResult,
+ type LocationBias,
+ type LocationRestriction,
} from './services/google.service';
import { useDebounce } from 'use-debounce';
import { useIsMounted } from './useIsMounted';
@@ -9,6 +12,62 @@ import { Platform } from 'react-native';
const isWeb = Platform.OS === 'web';
+/**
+ * Generate a RFC4122 v4 UUID. Uses crypto.randomUUID when available, otherwise
+ * falls back to a Math.random based implementation so it works across all
+ * React Native / Expo runtimes without extra native deps.
+ */
+function generateSessionToken(): string {
+ const cryptoObj =
+ typeof globalThis !== 'undefined'
+ ? (globalThis as { crypto?: Crypto }).crypto
+ : undefined;
+
+ if (cryptoObj?.randomUUID) {
+ return cryptoObj.randomUUID();
+ }
+
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ // eslint-disable-next-line no-bitwise
+ const r = (Math.random() * 16) | 0;
+ // eslint-disable-next-line no-bitwise
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
+ return v.toString(16);
+ });
+}
+
+/**
+ * Parse a legacy `components` string (e.g. "country:nl|country:be") into
+ * `includedRegionCodes` for Places API (New).
+ */
+function parseComponents(components?: string): string[] | undefined {
+ if (!components) {
+ return undefined;
+ }
+
+ const codes = Array.from(components.matchAll(/country:([a-zA-Z]{2,3})/g)).map(
+ (m) => m[1]!.toLowerCase()
+ );
+
+ return codes.length ? codes : undefined;
+}
+
+/**
+ * Map the legacy `queryTypes` value to `includedPrimaryTypes`. The legacy
+ * default of `address` and the `geocode`/mixed values have no direct primary
+ * type in the new API, so only `establishment` is mapped; anything else means
+ * "no type filter" (return all place types).
+ */
+function mapQueryTypes(queryTypes?: string): string[] | undefined {
+ if (!queryTypes) {
+ return undefined;
+ }
+ if (queryTypes === 'establishment') {
+ return ['establishment'];
+ }
+ return undefined;
+}
+
interface Options {
/**
* Minimun length of the input before start fetching - default: 2
@@ -30,18 +89,89 @@ interface Options {
equalityFn?: (left: any, right: any) => boolean;
};
+ // --- Places API (New) options ----------------------------------------------
+
+ /**
+ * Language for the request, e.g. "en", "nl". Maps to `languageCode`.
+ * Prefer this over the deprecated `language`.
+ */
+ languageCode?: string;
+
+ /**
+ * Region code (CLDR), used to format results, e.g. "nl".
+ */
+ regionCode?: string;
+
+ /**
+ * Restrict results to up to 15 country/region codes, e.g. ["nl", "be"].
+ */
+ includedRegionCodes?: string[];
+
+ /**
+ * Restrict results to the given place types, e.g. ["establishment"].
+ * See https://developers.google.com/maps/documentation/places/web-service/place-types
+ */
+ includedPrimaryTypes?: string[];
+
+ /**
+ * Bias results toward an area (soft). Prefer this for venue search.
+ */
+ locationBias?: LocationBias;
+
+ /**
+ * Restrict results to an area (hard).
+ */
+ locationRestriction?: LocationRestriction;
+
+ /**
+ * Include query predictions in addition to place predictions.
+ * Returned results carry `isQueryPrediction: true`.
+ */
+ includeQueryPredictions?: boolean;
+
+ /**
+ * Custom field mask for the autocomplete request.
+ */
+ autocompleteFieldMask?: string;
+
+ /**
+ * Custom field mask for the place details request. Required fields for your
+ * use case must be included. Defaults to a broad set.
+ */
+ placeDetailsFieldMask?: string;
+
+ /**
+ * Provide your own session token. By default one is generated and rotated
+ * after each `searchDetails` / `clearSearch` for session-based billing.
+ * See https://developers.google.com/maps/documentation/places/web-service/place-session-tokens
+ */
+ sessionToken?: string;
+
+ /**
+ * The origin point from which to calculate straight-line distance to each
+ * result (returned as `distance_meters`). If omitted, distance is not
+ * returned.
+ */
+ origin?: {
+ lat: number;
+ lng: number;
+ };
+
+ // --- Legacy options (deprecated, mapped to the new API) --------------------
+
/**
- * Language for Google query - default: en
+ * @deprecated Use `languageCode`. Language for Google query - default: en
*/
language?: string;
/**
- * A grouping of places to which you would like to restrict your results
+ * @deprecated Use `includedRegionCodes`. A "country:xx" components string.
*/
components?: string;
/**
- * See https://developers.google.com/places/web-service/autocomplete#place_types = default: address
+ * @deprecated Use `includedPrimaryTypes`. Only `establishment` maps cleanly;
+ * other values are ignored (no type filter).
*/
queryTypes?:
| 'address'
@@ -51,36 +181,26 @@ interface Options {
| 'geocode|establishment';
/**
- * The distance (in meters) within which to return place results.
- * Note that setting a radius biases results to the indicated area,
- * but may not fully restrict results to the specified area.
+ * @deprecated Use `locationBias` / `locationRestriction`. Radius in meters.
*/
- radius?: string;
+ radius?: string | number;
/**
- * The latitude to retrieve place information
+ * @deprecated Use `locationBias` / `locationRestriction`. Latitude.
*/
lat?: number;
/**
- * The longitude to retrieve place information
+ * @deprecated Use `locationBias` / `locationRestriction`. Longitude.
*/
lng?: number;
/**
- * Enable strict mode to return search result only in the area defined by radius, lat and lng
+ * @deprecated Use `locationRestriction`. When true the lat/lng/radius is
+ * treated as a hard restriction instead of a bias.
*/
strictBounds?: boolean;
- /**
- * The origin point from which to calculate straight-line distance to the destination (returned as distance_meters).
- * If omitted, straight-line distance will not be returned.
- */
- origin?: {
- lat: number;
- lng: number;
- };
-
/**
* Proxy url if you want to use the web, this is needed cause of CORS issue
*/
@@ -94,14 +214,51 @@ interface Options {
headers?: Record;
}
+/**
+ * Build the location bias/restriction from either the new options or the legacy
+ * lat/lng/radius/strictBounds options.
+ */
+function resolveLocation(opts: Options): {
+ locationBias?: LocationBias;
+ locationRestriction?: LocationRestriction;
+} {
+ if (opts.locationBias || opts.locationRestriction) {
+ return {
+ locationBias: opts.locationBias,
+ locationRestriction: opts.locationRestriction,
+ };
+ }
+
+ const { lat, lng, radius, strictBounds } = opts;
+
+ if ((lat == null) !== (lng == null)) {
+ throw new Error('Query: Location must have both lat & lng');
+ }
+
+ if (lat == null || lng == null) {
+ return {};
+ }
+
+ const circle: LocationBias = {
+ circle: {
+ center: { latitude: lat, longitude: lng },
+ radius: typeof radius === 'string' ? Number(radius) : radius ?? 50000,
+ },
+ };
+
+ return strictBounds
+ ? { locationRestriction: circle }
+ : { locationBias: circle };
+}
+
export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => {
- const {
- minLength = 2,
- debounce = 300,
- debounceOptions = {},
- language = 'en',
- queryTypes = 'address',
- } = opts;
+ const { minLength = 2, debounce = 300, debounceOptions = {} } = opts;
+ const languageCode = opts.languageCode ?? opts.language;
+ const includedRegionCodes =
+ opts.includedRegionCodes ?? parseComponents(opts.components);
+ const includedPrimaryTypes =
+ opts.includedPrimaryTypes ?? mapQueryTypes(opts.queryTypes);
+
const isMounted = useIsMounted();
const [isSearching, setIsSearching] = useState(false);
const [term, setTerm] = useState('');
@@ -111,37 +268,51 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => {
>([]);
const [searchError, setSearchError] = useState(null);
+ const sessionTokenRef = useRef(opts.sessionToken ?? generateSessionToken());
+
const search = async (value: string) => {
if (isWeb && !opts.proxyUrl) {
throw new Error('A proxy url is needed for web');
}
setIsSearching(true);
+ setSearchError(null);
try {
- const results = await GoogleService.search(
- value,
- {
- key: apiKey,
- language,
- types: queryTypes,
- strictBounds: opts.strictBounds,
- lat: opts.lat,
- lng: opts.lng,
- radius: opts.radius,
- components: opts.components,
- origin: opts.origin,
- },
- opts.proxyUrl,
- opts.headers
- );
-
- setLocationResults(results.predictions);
+ const { locationBias, locationRestriction } = resolveLocation(opts);
+
+ const request: AutocompleteRequest = {
+ input: value,
+ languageCode,
+ regionCode: opts.regionCode,
+ includedRegionCodes,
+ includedPrimaryTypes,
+ locationBias,
+ locationRestriction,
+ includeQueryPredictions: opts.includeQueryPredictions,
+ sessionToken: sessionTokenRef.current,
+ origin: opts.origin
+ ? { latitude: opts.origin.lat, longitude: opts.origin.lng }
+ : undefined,
+ };
+
+ const results = await GoogleService.search(request, {
+ apiKey,
+ fieldMask: opts.autocompleteFieldMask,
+ proxyUrl: opts.proxyUrl,
+ headers: opts.headers,
+ });
+
+ if (isMounted()) {
+ setLocationResults(results);
+ }
} catch (error) {
- if (error instanceof Error) {
+ if (error instanceof Error && isMounted()) {
setSearchError(error);
}
} finally {
- setIsSearching(false);
+ if (isMounted()) {
+ setIsSearching(false);
+ }
}
};
@@ -150,23 +321,27 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => {
throw new Error('A proxy url is needed for web');
}
- return GoogleService.searchDetails(
- placeId,
- {
- key: apiKey,
- language,
- types: queryTypes,
- components: opts.components,
- },
- opts.proxyUrl,
- opts.headers
- );
+ const details = await GoogleService.searchDetails(placeId, {
+ apiKey,
+ fieldMask: opts.placeDetailsFieldMask,
+ languageCode,
+ regionCode: opts.regionCode,
+ sessionToken: sessionTokenRef.current,
+ proxyUrl: opts.proxyUrl,
+ headers: opts.headers,
+ });
+
+ // Selecting a place ends the session; rotate the token for the next one.
+ sessionTokenRef.current = opts.sessionToken ?? generateSessionToken();
+
+ return details;
};
const clearSearch = () => {
if (isMounted()) {
setLocationResults([]);
setIsSearching(false);
+ sessionTokenRef.current = opts.sessionToken ?? generateSessionToken();
}
};
@@ -188,5 +363,6 @@ export const useGoogleAutocomplete = (apiKey: string, opts: Options = {}) => {
setTerm,
term,
searchDetails,
+ sessionToken: sessionTokenRef.current,
};
};
diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx
index bf84291..4c6ef20 100644
--- a/src/__tests__/index.test.tsx
+++ b/src/__tests__/index.test.tsx
@@ -1 +1,157 @@
-it.todo('write a test');
+import { GoogleService } from '../services/google.service';
+
+const mockFetch = (body: unknown, ok = true, status = 200) =>
+ jest.fn().mockResolvedValue({
+ ok,
+ status,
+ statusText: ok ? 'OK' : 'Error',
+ json: async () => body,
+ });
+
+describe('GoogleService.search (Places API New autocomplete)', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('posts to the new autocomplete endpoint with api key and field mask', async () => {
+ const fetchMock = mockFetch({ suggestions: [] });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ await GoogleService.search(
+ { input: 'arena', languageCode: 'nl' },
+ { apiKey: 'KEY' }
+ );
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://places.googleapis.com/v1/places:autocomplete');
+ expect(init.method).toBe('POST');
+ expect(init.headers['X-Goog-Api-Key']).toBe('KEY');
+ expect(init.headers['X-Goog-FieldMask']).toContain(
+ 'suggestions.placePrediction'
+ );
+ expect(JSON.parse(init.body)).toMatchObject({
+ input: 'arena',
+ languageCode: 'nl',
+ });
+ });
+
+ it('normalizes place predictions into the legacy result shape', async () => {
+ global.fetch = mockFetch({
+ suggestions: [
+ {
+ placePrediction: {
+ placeId: 'abc123',
+ text: { text: 'Johan Cruijff ArenA, Amsterdam' },
+ structuredFormat: {
+ mainText: {
+ text: 'Johan Cruijff ArenA',
+ matches: [{ endOffset: 5 }],
+ },
+ secondaryText: { text: 'Amsterdam, Netherlands' },
+ },
+ types: ['stadium', 'establishment'],
+ distanceMeters: 1234,
+ },
+ },
+ ],
+ }) as unknown as typeof fetch;
+
+ const results = await GoogleService.search(
+ { input: 'johan' },
+ { apiKey: 'KEY' }
+ );
+
+ expect(results).toHaveLength(1);
+ expect(results[0]).toMatchObject({
+ place_id: 'abc123',
+ description: 'Johan Cruijff ArenA, Amsterdam',
+ structured_formatting: {
+ main_text: 'Johan Cruijff ArenA',
+ secondary_text: 'Amsterdam, Netherlands',
+ main_text_matched_substrings: [{ offset: 0, length: 5 }],
+ },
+ types: ['stadium', 'establishment'],
+ distance_meters: 1234,
+ isQueryPrediction: false,
+ });
+ });
+
+ it('flags query predictions', async () => {
+ global.fetch = mockFetch({
+ suggestions: [
+ {
+ queryPrediction: {
+ text: { text: 'pizza near me' },
+ structuredFormat: { mainText: { text: 'pizza near me' } },
+ },
+ },
+ ],
+ }) as unknown as typeof fetch;
+
+ const results = await GoogleService.search(
+ { input: 'pizza' },
+ { apiKey: 'KEY' }
+ );
+
+ expect(results[0]!.isQueryPrediction).toBe(true);
+ expect(results[0]!.place_id).toBe('');
+ });
+
+ it('throws the API error message on non-ok responses', async () => {
+ global.fetch = mockFetch(
+ { error: { message: 'API key not valid' } },
+ false,
+ 403
+ ) as unknown as typeof fetch;
+
+ await expect(
+ GoogleService.search({ input: 'x' }, { apiKey: 'BAD' })
+ ).rejects.toThrow('API key not valid');
+ });
+});
+
+describe('GoogleService.searchDetails (Places API New details)', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('GETs the place by id with a field mask and normalizes the result', async () => {
+ const fetchMock = mockFetch({
+ id: 'abc123',
+ displayName: { text: 'Ziggo Dome' },
+ formattedAddress: 'De Passage 100, Amsterdam',
+ location: { latitude: 52.3138, longitude: 4.9377 },
+ types: ['stadium'],
+ primaryType: 'stadium',
+ googleMapsUri: 'https://maps.google.com/?cid=1',
+ websiteUri: 'https://www.ziggodome.nl/',
+ addressComponents: [
+ { longText: 'Amsterdam', shortText: 'AMS', types: ['locality'] },
+ ],
+ });
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ const details = await GoogleService.searchDetails('abc123', {
+ apiKey: 'KEY',
+ sessionToken: 'tok',
+ });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toContain('https://places.googleapis.com/v1/places/abc123');
+ expect(url).toContain('sessionToken=tok');
+ expect(init.method).toBe('GET');
+ expect(init.headers['X-Goog-FieldMask']).toBeDefined();
+
+ expect(details).toMatchObject({
+ place_id: 'abc123',
+ name: 'Ziggo Dome',
+ formatted_address: 'De Passage 100, Amsterdam',
+ website: 'https://www.ziggodome.nl/',
+ url: 'https://maps.google.com/?cid=1',
+ geometry: { location: { lat: 52.3138, lng: 4.9377 } },
+ address_components: [
+ { long_name: 'Amsterdam', short_name: 'AMS', types: ['locality'] },
+ ],
+ });
+ });
+});
diff --git a/src/index.tsx b/src/index.tsx
index 123c9f9..b83e731 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,25 @@
import { useGoogleAutocomplete } from './GoogleAutocomplete';
import {
+ GoogleService,
+ DEFAULT_AUTOCOMPLETE_FIELD_MASK,
+ DEFAULT_PLACE_DETAILS_FIELD_MASK,
type GoogleLocationDetailResult,
type GoogleLocationResult,
+ type AutocompleteRequest,
+ type Circle,
+ type LocationBias,
+ type LocationRestriction,
} from './services/google.service';
export {
useGoogleAutocomplete,
+ GoogleService,
+ DEFAULT_AUTOCOMPLETE_FIELD_MASK,
+ DEFAULT_PLACE_DETAILS_FIELD_MASK,
type GoogleLocationDetailResult,
type GoogleLocationResult,
+ type AutocompleteRequest,
+ type Circle,
+ type LocationBias,
+ type LocationRestriction,
};
diff --git a/src/services/google.service.ts b/src/services/google.service.ts
index 1059ba7..e44fa84 100644
--- a/src/services/google.service.ts
+++ b/src/services/google.service.ts
@@ -1,46 +1,188 @@
-import queryString from 'query-string';
-
-const BASE_URL = 'https://maps.googleapis.com/maps/api/place';
-
-export interface Query {
- language: string;
- key: string;
- types:
- | 'address'
- | 'geocode'
- | '(cities)'
- | 'establishment'
- | 'geocode|establishment';
- components?: string;
- radius?: string;
- lat?: number;
- lng?: number;
- strictBounds?: boolean;
+const BASE_URL = 'https://places.googleapis.com/v1';
+
+/**
+ * A circular geographic area used for locationBias / locationRestriction.
+ * See https://developers.google.com/maps/documentation/places/web-service/place-autocomplete
+ */
+export interface Circle {
+ center: {
+ latitude: number;
+ longitude: number;
+ };
+ radius: number;
+}
+
+export interface LocationBias {
+ circle?: Circle;
+ rectangle?: {
+ low: { latitude: number; longitude: number };
+ high: { latitude: number; longitude: number };
+ };
+}
+
+export type LocationRestriction = LocationBias;
+
+/**
+ * Request body for the Places API (New) Autocomplete endpoint.
+ * https://developers.google.com/maps/documentation/places/web-service/place-autocomplete
+ */
+export interface AutocompleteRequest {
+ input: string;
+ languageCode?: string;
+ regionCode?: string;
+ includedRegionCodes?: string[];
+ includedPrimaryTypes?: string[];
+ locationBias?: LocationBias;
+ locationRestriction?: LocationRestriction;
origin?: {
- lat: number;
- lng: number;
+ latitude: number;
+ longitude: number;
+ };
+ includeQueryPredictions?: boolean;
+ sessionToken?: string;
+ inputOffset?: number;
+}
+
+/**
+ * Default field mask for the autocomplete request. Field masks are optional for
+ * Autocomplete (New) but limit the response to the fields we normalize below.
+ */
+export const DEFAULT_AUTOCOMPLETE_FIELD_MASK = [
+ 'suggestions.placePrediction.placeId',
+ 'suggestions.placePrediction.text',
+ 'suggestions.placePrediction.structuredFormat',
+ 'suggestions.placePrediction.types',
+ 'suggestions.placePrediction.distanceMeters',
+ 'suggestions.queryPrediction.text',
+ 'suggestions.queryPrediction.structuredFormat',
+].join(',');
+
+/**
+ * Default field mask for the place details request. Field masks are REQUIRED for
+ * Place Details (New); omitting them returns an error.
+ */
+export const DEFAULT_PLACE_DETAILS_FIELD_MASK = [
+ 'id',
+ 'name',
+ 'displayName',
+ 'formattedAddress',
+ 'adrFormatAddress',
+ 'shortFormattedAddress',
+ 'location',
+ 'viewport',
+ 'types',
+ 'primaryType',
+ 'googleMapsUri',
+ 'websiteUri',
+ 'addressComponents',
+ 'utcOffsetMinutes',
+].join(',');
+
+// --- Raw shapes returned by Places API (New) ----------------------------------
+
+interface RawText {
+ text: string;
+ matches?: Array<{ startOffset?: number; endOffset: number }>;
+}
+
+interface RawStructuredFormat {
+ mainText?: RawText;
+ secondaryText?: RawText;
+}
+
+interface RawPlacePrediction {
+ placeId: string;
+ text?: RawText;
+ structuredFormat?: RawStructuredFormat;
+ types?: string[];
+ distanceMeters?: number;
+}
+
+interface RawQueryPrediction {
+ text?: RawText;
+ structuredFormat?: RawStructuredFormat;
+}
+
+interface RawSuggestion {
+ placePrediction?: RawPlacePrediction;
+ queryPrediction?: RawQueryPrediction;
+}
+
+interface RawAutocompleteResponse {
+ suggestions?: RawSuggestion[];
+}
+
+interface RawPlaceDetails {
+ id: string;
+ name?: string;
+ displayName?: { text: string; languageCode?: string };
+ formattedAddress?: string;
+ adrFormatAddress?: string;
+ shortFormattedAddress?: string;
+ location?: { latitude: number; longitude: number };
+ viewport?: {
+ low: { latitude: number; longitude: number };
+ high: { latitude: number; longitude: number };
};
+ types?: string[];
+ primaryType?: string;
+ googleMapsUri?: string;
+ websiteUri?: string;
+ utcOffsetMinutes?: number;
+ addressComponents?: Array<{
+ longText: string;
+ shortText: string;
+ types: string[];
+ }>;
}
+// --- Normalized shapes (kept backward compatible) -----------------------------
+
+/**
+ * Normalized autocomplete prediction. Field names mirror the legacy library so
+ * existing consumers keep working, with the raw new-API prediction attached on
+ * `_raw` for access to anything not normalized.
+ */
+export interface GoogleLocationResult {
+ description: string;
+ place_id: string;
+ structured_formatting: {
+ main_text: string;
+ secondary_text: string;
+ main_text_matched_substrings: Array<{
+ length: number;
+ offset?: number;
+ }>;
+ };
+ types: string[];
+ distance_meters?: number;
+ /** True when this is a query prediction rather than a place prediction. */
+ isQueryPrediction?: boolean;
+ _raw: RawPlacePrediction | RawQueryPrediction;
+}
+
+/**
+ * Normalized place details. Field names mirror the legacy library, with the raw
+ * new-API place attached on `_raw`.
+ */
export interface GoogleLocationDetailResult {
- adr_address: string;
- formatted_address: string;
- icon: string;
id: string;
name: string;
place_id: string;
- scope: string;
- reference: string;
+ formatted_address: string;
+ adr_address: string;
+ vicinity: string;
url: string;
+ website: string;
utc_offset: number;
- vicinity: string;
types: string[];
+ primary_type?: string;
geometry: {
location: {
lat: number;
lng: number;
};
- viewport: {
+ viewport?: {
[type: string]: {
lat: number;
lng: number;
@@ -52,130 +194,190 @@ export interface GoogleLocationDetailResult {
short_name: string;
types: string[];
}>;
+ _raw: RawPlaceDetails;
}
-export interface GoogleLocationResult {
- description: string;
- id: string;
- matched_substrings: Array<{
- length: number;
- offset: number;
- }>;
- place_id: string;
- reference: string;
- structured_formatting: {
- main_text: string;
- secondary_text: string;
- main_text_matched_substrings: Array<{
- length: number;
- }>;
- };
- terms: Array<{
- offset: number;
- value: string;
- }>;
- types: string[];
- distance_meters?: number;
-}
+function normalizeMatches(
+ text?: RawText
+): Array<{ length: number; offset?: number }> {
+ if (!text?.matches) {
+ return [];
+ }
-interface NormalizeQuery {
- language: string;
- key: string;
- types:
- | 'address'
- | 'geocode'
- | '(cities)'
- | 'establishment'
- | 'geocode|establishment';
- components?: string;
- radius?: string;
- location?: string;
- origin?: string;
- strictBounds?: boolean;
+ return text.matches.map((m) => ({
+ offset: m.startOffset ?? 0,
+ length: m.endOffset - (m.startOffset ?? 0),
+ }));
}
-const normalizeQuery = (query: Query): NormalizeQuery => {
- const { lat, lng, origin, ...rest } = query;
+function normalizePrediction(
+ prediction: RawPlacePrediction | RawQueryPrediction,
+ isQueryPrediction: boolean
+): GoogleLocationResult {
+ const placeId = (prediction as RawPlacePrediction).placeId ?? '';
+ const structured = prediction.structuredFormat;
- // The latitude/longitude around which to retrieve place information. This must be specified as latitude,longitude.
- let location;
-
- // If one of the value is provide lat/lng both must be there
- if ((lat && !lng) || (lng && !lat)) {
- throw new Error('Query: Location must have both lat & lng');
- }
-
- if (lat && lng) {
- location = `${lat},${lng}`;
- }
-
- // The origin point from which to calculate straight-line distance. This must be specified as latitude,longitude.
- let originStr;
- if (origin) {
- if (!origin.lat || !origin.lng) {
- throw new Error('Query: Origin must have both lat & lng');
- }
- originStr = `${origin.lat},${origin.lng}`;
- }
+ return {
+ place_id: placeId,
+ description: prediction.text?.text ?? '',
+ structured_formatting: {
+ main_text: structured?.mainText?.text ?? prediction.text?.text ?? '',
+ secondary_text: structured?.secondaryText?.text ?? '',
+ main_text_matched_substrings: normalizeMatches(structured?.mainText),
+ },
+ types: (prediction as RawPlacePrediction).types ?? [],
+ distance_meters: (prediction as RawPlacePrediction).distanceMeters,
+ isQueryPrediction,
+ _raw: prediction,
+ };
+}
+function normalizeDetails(place: RawPlaceDetails): GoogleLocationDetailResult {
return {
- ...rest,
- location,
- origin: originStr,
+ id: place.id,
+ place_id: place.id,
+ name: place.displayName?.text ?? place.name ?? '',
+ formatted_address: place.formattedAddress ?? '',
+ adr_address: place.adrFormatAddress ?? '',
+ vicinity: place.shortFormattedAddress ?? '',
+ url: place.googleMapsUri ?? '',
+ website: place.websiteUri ?? '',
+ utc_offset: place.utcOffsetMinutes ?? 0,
+ types: place.types ?? [],
+ primary_type: place.primaryType,
+ geometry: {
+ location: {
+ lat: place.location?.latitude ?? 0,
+ lng: place.location?.longitude ?? 0,
+ },
+ viewport: place.viewport
+ ? {
+ northeast: {
+ lat: place.viewport.high.latitude,
+ lng: place.viewport.high.longitude,
+ },
+ southwest: {
+ lat: place.viewport.low.latitude,
+ lng: place.viewport.low.longitude,
+ },
+ }
+ : undefined,
+ },
+ address_components: (place.addressComponents ?? []).map((c) => ({
+ long_name: c.longText,
+ short_name: c.shortText,
+ types: c.types,
+ })),
+ _raw: place,
};
-};
+}
+
+async function parseError(res: Response): Promise {
+ let message = res.statusText;
+ try {
+ const body = await res.json();
+ message = body?.error?.message ?? JSON.stringify(body) ?? message;
+ } catch {
+ // ignore body parse failure, fall back to statusText
+ }
+ return new Error(message || `Request failed with status ${res.status}`);
+}
export class GoogleService {
+ /**
+ * Autocomplete using Places API (New) - POST /v1/places:autocomplete.
+ */
public static async search(
- term: string,
- query: Query,
- proxyUrl?: string,
- headers?: Record
- ): Promise<{
- predictions: GoogleLocationResult[];
- status: string;
- }> {
- const url = `${BASE_URL}/autocomplete/json?&input=${encodeURIComponent(
- term
- )}&${queryString.stringify({ ...normalizeQuery(query) })}${
- query.strictBounds ? '&strictbounds' : ''
- }`;
-
- const _url = proxyUrl ? proxyUrl + url : url;
-
- const res = await fetch(_url, headers ? { headers } : undefined);
+ request: AutocompleteRequest,
+ options: {
+ apiKey: string;
+ fieldMask?: string;
+ proxyUrl?: string;
+ headers?: Record;
+ }
+ ): Promise {
+ const url = `${options.proxyUrl ?? ''}${BASE_URL}/places:autocomplete`;
+
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Goog-Api-Key': options.apiKey,
+ 'X-Goog-FieldMask':
+ options.fieldMask ?? DEFAULT_AUTOCOMPLETE_FIELD_MASK,
+ ...options.headers,
+ },
+ body: JSON.stringify(request),
+ });
if (!res.ok) {
- throw new Error(res.statusText);
+ throw await parseError(res);
}
- return res.json();
+ const json: RawAutocompleteResponse = await res.json();
+ const suggestions = json.suggestions ?? [];
+
+ return suggestions
+ .map((s) => {
+ if (s.placePrediction) {
+ return normalizePrediction(s.placePrediction, false);
+ }
+ if (s.queryPrediction) {
+ return normalizePrediction(s.queryPrediction, true);
+ }
+ return null;
+ })
+ .filter((r): r is GoogleLocationResult => r !== null);
}
+ /**
+ * Place details using Places API (New) - GET /v1/places/{placeId}.
+ */
public static async searchDetails(
- placeid: string,
- query: Query & { fields?: string },
- proxyUrl?: string,
- headers?: Record
+ placeId: string,
+ options: {
+ apiKey: string;
+ fieldMask?: string;
+ languageCode?: string;
+ regionCode?: string;
+ sessionToken?: string;
+ proxyUrl?: string;
+ headers?: Record;
+ }
): Promise {
- const url = `${BASE_URL}/details/json?${queryString.stringify({
- ...normalizeQuery(query),
- placeid,
- })}`;
-
- const _url = proxyUrl ? proxyUrl + url : url;
+ const params = new URLSearchParams();
+ if (options.languageCode) {
+ params.set('languageCode', options.languageCode);
+ }
+ if (options.regionCode) {
+ params.set('regionCode', options.regionCode);
+ }
+ if (options.sessionToken) {
+ params.set('sessionToken', options.sessionToken);
+ }
- const res = await fetch(_url, headers ? { headers } : undefined);
+ const query = params.toString();
+ const url = `${options.proxyUrl ?? ''}${BASE_URL}/places/${encodeURIComponent(
+ placeId
+ )}${query ? `?${query}` : ''}`;
- const resJson: {
- status: string;
- result: GoogleLocationDetailResult;
- } = await res.json();
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Goog-Api-Key': options.apiKey,
+ 'X-Goog-FieldMask':
+ options.fieldMask ?? DEFAULT_PLACE_DETAILS_FIELD_MASK,
+ ...options.headers,
+ },
+ });
- if (!resJson.status) {
- throw new Error(res.statusText);
+ if (!res.ok) {
+ throw await parseError(res);
}
- return Promise.resolve(resJson.result);
+ const place: RawPlaceDetails = await res.json();
+
+ return normalizeDetails(place);
}
}
diff --git a/yarn.lock b/yarn.lock
index fde0d47..67b44dd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -60,7 +60,6 @@ __metadata:
eslint-plugin-prettier: ^5.0.1
jest: ^29.7.0
prettier: ^3.0.3
- query-string: 7.1.3
react: 18.2.0
react-native: 0.74.3
react-native-builder-bob: ^0.25.0
@@ -6051,13 +6050,6 @@ __metadata:
languageName: node
linkType: hard
-"decode-uri-component@npm:^0.2.2":
- version: 0.2.2
- resolution: "decode-uri-component@npm:0.2.2"
- checksum: 95476a7d28f267292ce745eac3524a9079058bbb35767b76e3ee87d42e34cd0275d2eb19d9d08c3e167f97556e8a2872747f5e65cbebcac8b0c98d83e285f139
- languageName: node
- linkType: hard
-
"decompress-response@npm:^6.0.0":
version: 6.0.0
resolution: "decompress-response@npm:6.0.0"
@@ -7431,13 +7423,6 @@ __metadata:
languageName: node
linkType: hard
-"filter-obj@npm:^1.1.0":
- version: 1.1.0
- resolution: "filter-obj@npm:1.1.0"
- checksum: cf2104a7c45ff48e7f505b78a3991c8f7f30f28bd8106ef582721f321f1c6277f7751aacd5d83026cb079d9d5091082f588d14a72e7c5d720ece79118fa61e10
- languageName: node
- linkType: hard
-
"finalhandler@npm:1.1.2":
version: 1.1.2
resolution: "finalhandler@npm:1.1.2"
@@ -12469,18 +12454,6 @@ __metadata:
languageName: node
linkType: hard
-"query-string@npm:7.1.3":
- version: 7.1.3
- resolution: "query-string@npm:7.1.3"
- dependencies:
- decode-uri-component: ^0.2.2
- filter-obj: ^1.1.0
- split-on-first: ^1.0.0
- strict-uri-encode: ^2.0.0
- checksum: 91af02dcd9cc9227a052841d5c2eecb80a0d6489d05625df506a097ef1c59037cfb5e907f39b84643cbfd535c955abec3e553d0130a7b510120c37d06e0f4346
- languageName: node
- linkType: hard
-
"querystring@npm:^0.2.1":
version: 0.2.1
resolution: "querystring@npm:0.2.1"
@@ -13794,13 +13767,6 @@ __metadata:
languageName: node
linkType: hard
-"split-on-first@npm:^1.0.0":
- version: 1.1.0
- resolution: "split-on-first@npm:1.1.0"
- checksum: 16ff85b54ddcf17f9147210a4022529b343edbcbea4ce977c8f30e38408b8d6e0f25f92cd35b86a524d4797f455e29ab89eb8db787f3c10708e0b47ebf528d30
- languageName: node
- linkType: hard
-
"split2@npm:^3.0.0, split2@npm:^3.2.2":
version: 3.2.2
resolution: "split2@npm:3.2.2"
@@ -13906,13 +13872,6 @@ __metadata:
languageName: node
linkType: hard
-"strict-uri-encode@npm:^2.0.0":
- version: 2.0.0
- resolution: "strict-uri-encode@npm:2.0.0"
- checksum: eaac4cf978b6fbd480f1092cab8b233c9b949bcabfc9b598dd79a758f7243c28765ef7639c876fa72940dac687181b35486ea01ff7df3e65ce3848c64822c581
- languageName: node
- linkType: hard
-
"string-length@npm:^4.0.1":
version: 4.0.2
resolution: "string-length@npm:4.0.2"