Skip to content

Commit d6fdefb

Browse files
authored
Merge pull request #133 from karthikcsq/fix/maps-128
Name the Google Cloud API in Maps authorization failures (#128)
2 parents 01ce5a1 + 887f141 commit d6fdefb

2 files changed

Lines changed: 87 additions & 1 deletion

File tree

dist/tools/maps/mapsClient.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ export function redactSecrets(text) {
3535
return String(redacted).replace(SECRET_QUERY_PARAM_PATTERN, '$1[REDACTED]');
3636
}
3737

38+
export function mapsApiNameForUrl(url) {
39+
if (url.startsWith('https://places.googleapis.com/v1/')) return 'Places API (New)';
40+
if (url.startsWith('https://maps.googleapis.com/maps/api/geocode/')) return 'Geocoding API';
41+
if (url.startsWith('https://routes.googleapis.com/directions/v2:computeRoutes')) return 'Routes API';
42+
return 'the Google Maps Platform API';
43+
}
44+
3845
export async function mapsFetch(url, options = {}) {
3946
let response;
4047
try { response = await fetch(url, options); }
@@ -52,7 +59,14 @@ export async function mapsFetch(url, options = {}) {
5259
if (!response.ok || apiStatusError) {
5360
const status = data?.error?.status || data?.status || response.status;
5461
const message = data?.error?.message || data?.error_message || response.statusText || 'Unknown error';
55-
throw new UserError(redactSecrets(`Google Maps API error (${status}): ${message}`));
62+
const authorizationFailure = data?.error?.status === 'PERMISSION_DENIED'
63+
|| data?.status === 'REQUEST_DENIED'
64+
|| response.status === 403;
65+
const displayMessage = authorizationFailure && !/[.!?]$/.test(message) ? `${message}.` : message;
66+
const guidance = authorizationFailure
67+
? ` This usually means the ${mapsApiNameForUrl(url)} is not enabled on the Google Cloud project for GOOGLE_MAPS_API_KEY, or the key has API restrictions that exclude it. Enable it at https://console.cloud.google.com/apis/library and check the key's restrictions at https://console.cloud.google.com/apis/credentials. This key is separate from Google OAuth, so other working tools do not confirm it is configured.`
68+
: '';
69+
throw new UserError(redactSecrets(`Google Maps API error (${status}): ${displayMessage}${guidance}`));
5670
}
5771
return data;
5872
}

tests/maps.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeAll, describe, expect, it, jest } from '@jest/globals';
22
import { UserError } from '../dist/errors.js';
3+
import { mapsApiNameForUrl, mapsFetch } from '../dist/tools/maps/mapsClient.js';
34

45
function createMockServer() {
56
const tools = new Map();
@@ -24,6 +25,77 @@ afterEach(() => {
2425
});
2526

2627
describe('Maps tools', () => {
28+
it.each([
29+
['https://places.googleapis.com/v1/places:searchText', 'Places API (New)'],
30+
['https://maps.googleapis.com/maps/api/geocode/json?address=123+Main', 'Geocoding API'],
31+
['https://routes.googleapis.com/directions/v2:computeRoutes', 'Routes API'],
32+
['https://example.com/unknown', 'the Google Maps Platform API'],
33+
])('names the Maps API for %s', (url, apiName) => {
34+
expect(mapsApiNameForUrl(url)).toBe(apiName);
35+
});
36+
37+
it('guides a Places API (New) permission denial without dropping its status', async () => {
38+
global.fetch = jest.fn().mockResolvedValue(response(
39+
{ error: { status: 'PERMISSION_DENIED', message: 'API not enabled' } },
40+
{ ok: false, status: 403, statusText: 'Forbidden' },
41+
));
42+
await expect(mapsFetch('https://places.googleapis.com/v1/places:searchText')).rejects.toThrow(
43+
'Google Maps API error (PERMISSION_DENIED): API not enabled. This usually means the Places API (New) is not enabled on the Google Cloud project for GOOGLE_MAPS_API_KEY, or the key has API restrictions that exclude it. Enable it at https://console.cloud.google.com/apis/library and check the key\'s restrictions at https://console.cloud.google.com/apis/credentials. This key is separate from Google OAuth, so other working tools do not confirm it is configured.',
44+
);
45+
});
46+
47+
it('guides a legacy Geocoding API request denial', async () => {
48+
global.fetch = jest.fn().mockResolvedValue(response(
49+
{ status: 'REQUEST_DENIED', error_message: 'API key is not authorized' },
50+
{ ok: false, status: 403, statusText: 'Forbidden' },
51+
));
52+
await expect(mapsFetch('https://maps.googleapis.com/maps/api/geocode/json?address=123+Main')).rejects.toThrow(
53+
'Google Maps API error (REQUEST_DENIED): API key is not authorized. This usually means the Geocoding API is not enabled on the Google Cloud project for GOOGLE_MAPS_API_KEY, or the key has API restrictions that exclude it. Enable it at https://console.cloud.google.com/apis/library and check the key\'s restrictions at https://console.cloud.google.com/apis/credentials. This key is separate from Google OAuth, so other working tools do not confirm it is configured.',
54+
);
55+
});
56+
57+
it('guides a bare Routes API HTTP 403', async () => {
58+
global.fetch = jest.fn().mockResolvedValue({
59+
ok: false,
60+
status: 403,
61+
statusText: 'Forbidden',
62+
json: jest.fn().mockRejectedValue(new SyntaxError('Unexpected end of JSON input')),
63+
});
64+
await expect(mapsFetch('https://routes.googleapis.com/directions/v2:computeRoutes')).rejects.toThrow(
65+
'Google Maps API error (403): Forbidden. This usually means the Routes API is not enabled on the Google Cloud project for GOOGLE_MAPS_API_KEY, or the key has API restrictions that exclude it. Enable it at https://console.cloud.google.com/apis/library and check the key\'s restrictions at https://console.cloud.google.com/apis/credentials. This key is separate from Google OAuth, so other working tools do not confirm it is configured.',
66+
);
67+
});
68+
69+
it('keeps non-authorization API errors byte-identical to main', async () => {
70+
global.fetch = jest.fn()
71+
.mockResolvedValueOnce(response(
72+
{ status: 'OVER_QUERY_LIMIT', error_message: 'Daily quota exceeded' },
73+
{ ok: false, status: 429, statusText: 'Too Many Requests' },
74+
))
75+
.mockResolvedValueOnce(response(null, { ok: false, status: 500, statusText: 'Internal Server Error' }));
76+
await expect(mapsFetch('https://places.googleapis.com/v1/places:searchText')).rejects.toThrow(
77+
'Google Maps API error (OVER_QUERY_LIMIT): Daily quota exceeded',
78+
);
79+
await expect(mapsFetch('https://places.googleapis.com/v1/places:searchText')).rejects.toThrow(
80+
'Google Maps API error (500): Internal Server Error',
81+
);
82+
});
83+
84+
it('redacts a key query parameter in a permission denial', async () => {
85+
global.fetch = jest.fn().mockResolvedValue(response(
86+
{ error: { status: 'PERMISSION_DENIED', message: 'Failed URL https://places.googleapis.com/v1/places?key=test-key' } },
87+
{ ok: false, status: 403, statusText: 'Forbidden' },
88+
));
89+
let thrown;
90+
try {
91+
await mapsFetch('https://places.googleapis.com/v1/places?key=test-key');
92+
} catch (error) {
93+
thrown = error;
94+
}
95+
expect(thrown.message).toContain('[REDACTED]');
96+
expect(thrown.message).not.toContain('key=test-key');
97+
});
98+
2799
it('sends the API key and a narrow Places field mask', async () => {
28100
process.env.GOOGLE_MAPS_API_KEY = 'test-key';
29101
global.fetch = jest.fn().mockResolvedValue(response({ places: [] }));

0 commit comments

Comments
 (0)