diff --git a/docs/lib/providers.ts b/docs/lib/providers.ts index 9f755927f..3315c9e02 100644 --- a/docs/lib/providers.ts +++ b/docs/lib/providers.ts @@ -4,6 +4,7 @@ import { EsriProvider, GeocodeEarthProvider, GoogleProvider, + LegacyGoogleProvider, HereProvider, LocationIQProvider, OpenCageProvider, @@ -25,7 +26,9 @@ export default { params: { api_key: process.env.GATSBY_GEOCODEEARTH_API_KEY }, }), - Google: new GoogleProvider({ + Google: new GoogleProvider({ apiKey: process.env.GATSBY_GOOGLE_API_KEY }), + + LegacyGoogle: new LegacyGoogleProvider({ params: { key: process.env.GATSBY_GOOGLE_API_KEY }, }), diff --git a/docs/providers/google.mdx b/docs/providers/google.mdx index e9618897d..c4a485251 100644 --- a/docs/providers/google.mdx +++ b/docs/providers/google.mdx @@ -9,8 +9,9 @@ import Map from '../components/Map'; # Google Provider -**note**: Google services require an API key. [Obtain here][1]. -For more options and configurations, see the [Google Maps developer docs][2]. +**note**: Google services require an API key enabled for _both_ the **Geocoding API** and +the **Maps JavaScript API** (more on key configuration below). [Obtain here][1]. For more options and configurations, +see the [Google Maps developer docs][2]. @@ -19,11 +20,7 @@ For more options and configurations, see the [Google Maps developer docs][2]. ```js import { GoogleProvider } from 'leaflet-geosearch'; -const provider = new GoogleProvider({ - params: { - key: '__YOUR_GOOGLE_KEY__', - }, -}); +const provider = new GoogleProvider({ apiKey: '__YOUR_GOOGLE_KEY__' }); // add to leaflet import { GeoSearchControl } from 'leaflet-geosearch'; @@ -38,20 +35,29 @@ map.addControl( ## Optional parameters -Google supports a number of [optional parameters][3]. As Google requires those parameters to be added to the url, they can be added to the `params` key of the provider. - -All options defined next to the `params` key, would have been added to the request body. +Google supports a number of [optional parameters][3] which must be specified when loading the JavaScript API. ```js -const provider = new GoogleProvider({ - params: { - key: '__YOUR_GOOGLE_KEY__', - language: 'nl', // render results in Dutch - region: 'nl', // prioritize matches within The Netherlands - }, -}); +const params = { + apiKey: '__YOUR_GOOGLE_KEY__', + language: 'nl', // render results in Dutch + region: 'nl', // prioritize matches within The Netherlands +}; + +new GoogleProvider({ ...params }); ``` +## Configuring your API key + +### Application restrictions (highly recommended) + +Because your API key will be exposed with each request from the client, it is _highly_ recommended that you should add an application restriction to any of your public-facing keys to limit the HTTP referrers from which requests will be accepted - for more information on how to configure this restriction, see [the documentation][4]. + +### API restrictions (optional) + +Your may choose not to apply any API restrictions to your key, leaving it in an "Unrestricted" state. However, if you do wish to limit the APIs that can be accessed with your key, you will need to make sure that it as configured to at least make use of _both_ the **Geocoding API** and the **Maps JavaScript API** in order for web client-based geocoding to work. + [1]: https://developers.google.com/maps/documentation/javascript/get-api-key -[2]: https://developers.google.com/maps/documentation/geocoding/start -[3]: https://developers.google.com/maps/documentation/geocoding/intro#geocoding +[2]: https://developers.google.com/maps/documentation/javascript/overview +[3]: https://googlemaps.github.io/js-api-loader/interfaces/LoaderOptions.html +[4]: https://cloud.google.com/docs/authentication/api-keys#http diff --git a/docs/providers/legacy-google.mdx b/docs/providers/legacy-google.mdx new file mode 100644 index 000000000..eaddf70e4 --- /dev/null +++ b/docs/providers/legacy-google.mdx @@ -0,0 +1,63 @@ +--- +name: LegacyGoogle +menu: Providers +route: /providers/legacy-google +--- + +import Playground from '../components/Playground'; +import Map from '../components/Map'; + +# Legacy Google Provider + +**WARNING: This provider is unsafe to use and should be considered DEPRECATED. +It is strongly suggested to use the new [Google Provider][4]. If you're currently migrating from a previous version +and you still wish to use this provider you should pull in the `LegacyGoogleProvider` class +and rename your references accordingly.** + +**note**: Google services require an API key. [Obtain here][1]. +For more options and configurations, see the [Google Maps developer docs][2]. + + + + + +```js +import { LegacyGoogleProvider } from 'leaflet-geosearch'; + +const provider = new LegacyGoogleProvider({ + params: { + key: '__YOUR_GOOGLE_KEY__', + }, +}); + +// add to leaflet +import { GeoSearchControl } from 'leaflet-geosearch'; + +map.addControl( + new GeoSearchControl({ + provider, + style: 'bar', + }), +); +``` + +## Optional parameters + +Google supports a number of [optional parameters][3]. As Google requires those parameters to be added to the url, they can be added to the `params` key of the provider. + +All options defined next to the `params` key, would have been added to the request body. + +```js +const provider = new LegacyGoogleProvider({ + params: { + key: '__YOUR_GOOGLE_KEY__', + language: 'nl', // render results in Dutch + region: 'nl', // prioritize matches within The Netherlands + }, +}); +``` + +[1]: https://developers.google.com/maps/documentation/javascript/get-api-key +[2]: https://developers.google.com/maps/documentation/geocoding/start +[3]: https://developers.google.com/maps/documentation/geocoding/intro#geocoding +[4]: /providers/google diff --git a/package-lock.json b/package-lock.json index bff273612..1cb67045f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.6.1", "license": "MIT", "devDependencies": { + "@types/google.maps": "^3.50.2", "@types/jest": "^29.1.1", "@types/lodash.debounce": "^4.0.6", "@types/react-dom": "^18.0.6", @@ -39,6 +40,7 @@ "typescript": "^4.8.4" }, "optionalDependencies": { + "@googlemaps/js-api-loader": "^1.14.3", "leaflet": "^1.6.0" } }, @@ -2724,6 +2726,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@googlemaps/js-api-loader": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.14.3.tgz", + "integrity": "sha512-6iIb+qpGgQpgIHmIFO44WhE1rDUxPVHuezNFL30wRJnkvhwFm94tD291UvNg9L05hLDSoL16jd0lbqqmdy4C5g==", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + } + }, + "node_modules/@googlemaps/js-api-loader/node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "optional": true + }, "node_modules/@graphql-tools/batch-execute": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-7.1.2.tgz", @@ -5312,6 +5329,12 @@ "@types/node": "*" } }, + "node_modules/@types/google.maps": { + "version": "3.50.2", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.50.2.tgz", + "integrity": "sha512-F47YMR1sdAVYk6mWab1J9CyO8J5QnCl62QGx9i87cTB2VW5/j2V5b/qgpXTvtUCg91PffirYKiAXlff/XTp+Zw==", + "dev": true + }, "node_modules/@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", @@ -44957,6 +44980,23 @@ } } }, + "@googlemaps/js-api-loader": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@googlemaps/js-api-loader/-/js-api-loader-1.14.3.tgz", + "integrity": "sha512-6iIb+qpGgQpgIHmIFO44WhE1rDUxPVHuezNFL30wRJnkvhwFm94tD291UvNg9L05hLDSoL16jd0lbqqmdy4C5g==", + "optional": true, + "requires": { + "fast-deep-equal": "^3.1.3" + }, + "dependencies": { + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "optional": true + } + } + }, "@graphql-tools/batch-execute": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-7.1.2.tgz", @@ -46991,6 +47031,12 @@ "@types/node": "*" } }, + "@types/google.maps": { + "version": "3.50.2", + "resolved": "https://registry.npmjs.org/@types/google.maps/-/google.maps-3.50.2.tgz", + "integrity": "sha512-F47YMR1sdAVYk6mWab1J9CyO8J5QnCl62QGx9i87cTB2VW5/j2V5b/qgpXTvtUCg91PffirYKiAXlff/XTp+Zw==", + "dev": true + }, "@types/graceful-fs": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz", diff --git a/package.json b/package.json index 9936c5fdd..68ae15d3e 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "homepage": "https://github.com/smeijer/leaflet-geosearch#readme", "license": "MIT", "devDependencies": { + "@types/google.maps": "^3.50.2", "@types/jest": "^29.1.1", "@types/lodash.debounce": "^4.0.6", "@types/react-dom": "^18.0.6", @@ -100,6 +101,7 @@ "typescript": "^4.8.4" }, "optionalDependencies": { + "@googlemaps/js-api-loader": "^1.14.3", "leaflet": "^1.6.0" }, "husky": { diff --git a/src/index.ts b/src/index.ts index 2f2f1749b..eb196a4a2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { default as BingProvider } from './providers/bingProvider'; export { default as EsriProvider } from './providers/esriProvider'; export { default as GeocodeEarthProvider } from './providers/geocodeEarthProvider'; export { default as GoogleProvider } from './providers/googleProvider'; +export { default as LegacyGoogleProvider } from './providers/legacyGoogleProvider'; export { default as HereProvider } from './providers/hereProvider'; export { default as LocationIQProvider } from './providers/locationIQProvider'; export { default as OpenCageProvider } from './providers/openCageProvider'; diff --git a/src/providers/__tests__/bingResponse.json b/src/providers/__tests__/bingResponse.json index 39b1aa135..825f47cda 100644 --- a/src/providers/__tests__/bingResponse.json +++ b/src/providers/__tests__/bingResponse.json @@ -9,7 +9,9 @@ { "__type": "Location:http://schemas.microsoft.com/search/local/ws/rest/v1", "bbox": [ - 52.099399566650391, 4.2973999977111816, 52.099601745605469, + 52.099399566650391, + 4.2973999977111816, + 52.099601745605469, 4.2975997924804687 ], "name": "Madurodam, Netherlands", diff --git a/src/providers/__tests__/googleGeocoderResponse.ts b/src/providers/__tests__/googleGeocoderResponse.ts new file mode 100644 index 000000000..94128dd10 --- /dev/null +++ b/src/providers/__tests__/googleGeocoderResponse.ts @@ -0,0 +1,75 @@ +const geocoderGeocoderResponse: google.maps.GeocoderResult = { + address_components: [ + { + long_name: '1', + short_name: '1', + types: ['street_number'], + }, + { + long_name: 'George Maduroplein', + short_name: 'George Maduroplein', + types: ['route'], + }, + { + long_name: 'Scheveningen', + short_name: 'Scheveningen', + types: ['political', 'sublocality', 'sublocality_level_1'], + }, + { + long_name: 'Den Haag', + short_name: 'Den Haag', + types: ['locality', 'political'], + }, + { + long_name: 'Den Haag', + short_name: 'Den Haag', + types: ['administrative_area_level_2', 'political'], + }, + { + long_name: 'Zuid-Holland', + short_name: 'ZH', + types: ['administrative_area_level_1', 'political'], + }, + { + long_name: 'Netherlands', + short_name: 'NL', + types: ['country', 'political'], + }, + { + long_name: '2584 RZ', + short_name: '2584 RZ', + types: ['postal_code'], + }, + ], + formatted_address: 'George Maduroplein 1, 2584 RZ Den Haag, Netherlands', + geometry: { + location: { + toJSON: () => { + return { + lat: 52.0994757, + lng: 4.2969304, + } as google.maps.LatLngLiteral; + }, + } as google.maps.LatLng, + location_type: 'ROOFTOP' as google.maps.GeocoderLocationType.ROOFTOP, + viewport: { + toJSON: () => { + return { + north: 52.1008246802915, + east: 4.298279380291502, + south: 52.0981267197085, + west: 4.295581419708498, + } as google.maps.LatLngBoundsLiteral; + }, + } as google.maps.LatLngBounds, + }, + place_id: 'ChIJx9INHE23xUcRQLyRyK-B_sw', + types: [ + 'amusement_park', + 'establishment', + 'point_of_interest', + 'tourist_attraction', + ], +}; + +export default geocoderGeocoderResponse; diff --git a/src/providers/__tests__/googleProvider.spec.js b/src/providers/__tests__/googleProvider.spec.js index 3f2c412dd..f489b64aa 100644 --- a/src/providers/__tests__/googleProvider.spec.js +++ b/src/providers/__tests__/googleProvider.spec.js @@ -1,36 +1,46 @@ +import { Loader } from '@googlemaps/js-api-loader'; +import geocoderGeocoderResponse from './googleGeocoderResponse'; import Provider from '../googleProvider'; -import fixtures from './googleResponse.json'; -describe('GoogleProvider', () => { - beforeAll(() => { - fetch.mockResponse(async () => ({ body: JSON.stringify(fixtures) })); - }); +const fixtures = [geocoderGeocoderResponse]; - test('Can fetch results', async () => { - const provider = new Provider({ - params: { - key: process.env.GOOGLE_API_KEY, +jest.mock('@googlemaps/js-api-loader'); + +jest.spyOn(Loader.prototype, 'load').mockImplementation(async () => { + return { + maps: { + Geocoder: function () { + this.geocode = async (_, callback) => { + return callback(fixtures, 'OK'); + }; }, - }); + }, + }; +}); + +let provider = {}; +beforeEach(async () => { + provider = new Provider({ apiKey: process.env.GOOGLE_API_KEY }); +}); + +describe('GoogleProvider', () => { + test('Can fetch results', async () => { const results = await provider.search({ query: 'Madurodam' }); const result = results[0]; + const { lng, lat } = fixtures[0].geometry.location.toJSON(); + expect(result.label).toBeTruthy(); - expect(result.x).toEqual(fixtures.results[0].geometry.location.lng); - expect(result.y).toEqual(fixtures.results[0].geometry.location.lat); + expect(result.x).toEqual(lng); + expect(result.y).toEqual(lat); expect(result.bounds).toBeValidBounds(); }); test.skip('Can get localized results', async () => { - const provider = new Provider({ - params: { - key: process.env.GOOGLE_API_KEY, - language: 'nl', - }, - }); - const results = await provider.search({ query: 'Madurodam' }); - t.is(results[0].label, 'Madurodam'); + expect(results[0].label).toEqual( + 'George Maduroplein 1, 2584 RZ Den Haag, Netherlands', + ); }); }); diff --git a/src/providers/__tests__/googleResponse.json b/src/providers/__tests__/googleResponse.json index 4c51a8186..2c9faca18 100644 --- a/src/providers/__tests__/googleResponse.json +++ b/src/providers/__tests__/googleResponse.json @@ -70,7 +70,6 @@ "amusement_park", "establishment", "point_of_interest", - "premise", "tourist_attraction" ] } diff --git a/src/providers/__tests__/legacyGoogleProvider.spec.js b/src/providers/__tests__/legacyGoogleProvider.spec.js new file mode 100644 index 000000000..b9c04603a --- /dev/null +++ b/src/providers/__tests__/legacyGoogleProvider.spec.js @@ -0,0 +1,36 @@ +import Provider from '../legacyGoogleProvider'; +import fixtures from './googleResponse.json'; + +describe('LegacyGoogleProvider', () => { + beforeAll(() => { + fetch.mockResponse(async () => ({ body: JSON.stringify(fixtures) })); + }); + + test('Can fetch results', async () => { + const provider = new Provider({ + params: { + key: process.env.GOOGLE_API_KEY, + }, + }); + + const results = await provider.search({ query: 'Madurodam' }); + const result = results[0]; + + expect(result.label).toBeTruthy(); + expect(result.x).toEqual(fixtures.results[0].geometry.location.lng); + expect(result.y).toEqual(fixtures.results[0].geometry.location.lat); + expect(result.bounds).toBeValidBounds(); + }); + + test.skip('Can get localized results', async () => { + const provider = new Provider({ + params: { + key: process.env.GOOGLE_API_KEY, + language: 'nl', + }, + }); + + const results = await provider.search({ query: 'Madurodam' }); + t.is(results[0].label, 'Madurodam'); + }); +}); diff --git a/src/providers/googleProvider.ts b/src/providers/googleProvider.ts index fb50da753..0660a1df9 100644 --- a/src/providers/googleProvider.ts +++ b/src/providers/googleProvider.ts @@ -1,59 +1,92 @@ import AbstractProvider, { EndpointArgument, - LatLng, ParseArgument, + ProviderOptions, + SearchArgument, SearchResult, } from './provider'; +import { Loader, LoaderOptions } from '@googlemaps/js-api-loader'; -export interface RequestResult { - results: RawResult[]; - status: string; +interface RequestResult { + results: google.maps.GeocoderResult[]; + status?: google.maps.GeocoderStatus; } -export interface RawResult { - address_components: { - long_name: string; - short_name: string; - types: string[]; - }[]; - formatted_address: string; - geometry: { - location: LatLng; - location_type: string; - viewport: { - northeast: LatLng; - southwest: LatLng; - }; - }; - place_id: string; - plus_code: { - compound_code: string; - global_code: string; - }; - types: string[]; +interface GeocodeError { + code: Exclude; + endpoint: 'GEOCODER_GEOCODE'; + message: string; + name: 'MapsRequestError'; + stack: string; } +export type GoogleProviderOptions = LoaderOptions & ProviderOptions; + export default class GoogleProvider extends AbstractProvider< RequestResult, - RawResult + google.maps.GeocoderResult > { - searchUrl = 'https://maps.googleapis.com/maps/api/geocode/json'; + loader: Promise | null = null; + geocoder: google.maps.Geocoder | null = null; + + constructor(options: GoogleProviderOptions) { + super(options); + + if (typeof window !== 'undefined') { + this.loader = new Loader(options).load().then((google) => { + const geocoder = new google.maps.Geocoder(); + this.geocoder = geocoder; + return geocoder; + }); + } + } + + endpoint({ query }: EndpointArgument): never { + throw new Error('Method not implemented.'); + } + + parse( + response: ParseArgument, + ): SearchResult[] { + return response.data.results.map((r) => { + const { lat, lng } = r.geometry.location.toJSON(); + const { east, north, south, west } = r.geometry.viewport.toJSON(); - endpoint({ query }: EndpointArgument) { - const params = typeof query === 'string' ? { address: query } : query; - return this.getUrl(this.searchUrl, params); + return { + x: lng, + y: lat, + label: r.formatted_address, + bounds: [ + [south, west], + [north, east], + ], + raw: r, + }; + }); } - parse(result: ParseArgument): SearchResult[] { - return result.data.results.map((r) => ({ - x: r.geometry.location.lng, - y: r.geometry.location.lat, - label: r.formatted_address, - bounds: [ - [r.geometry.viewport.southwest.lat, r.geometry.viewport.southwest.lng], // s, w - [r.geometry.viewport.northeast.lat, r.geometry.viewport.northeast.lng], // n, e - ], - raw: r, - })); + async search( + options: SearchArgument, + ): Promise[]> { + const geocoder = this.geocoder || (await this.loader); + + if (!geocoder) { + throw new Error( + 'GoogleMaps GeoCoder is not loaded. Are you trying to run this server side?', + ); + } + + const response = await geocoder + .geocode({ address: options.query }, (response) => ({ + results: response, + })) + .catch((e: GeocodeError) => { + if (e.code !== 'ZERO_RESULTS') { + console.error(`${e.code}: ${e.message}`); + } + return { results: [] }; + }); + + return this.parse({ data: response }); } } diff --git a/src/providers/index.ts b/src/providers/index.ts index e78bc88ae..d7843d42b 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -3,6 +3,7 @@ export { default as BingProvider } from './bingProvider'; export { default as EsriProvider } from './esriProvider'; export { default as GeocodeEarthProvider } from './geocodeEarthProvider'; export { default as GoogleProvider } from './googleProvider'; +export { default as LegacyGoogleProvider } from './legacyGoogleProvider'; export { default as LocationIQProvider } from './locationIQProvider'; export { default as OpenCageProvider } from './openCageProvider'; export { default as OpenStreetMapProvider } from './openStreetMapProvider'; diff --git a/src/providers/legacyGoogleProvider.ts b/src/providers/legacyGoogleProvider.ts new file mode 100644 index 000000000..8145aa2e1 --- /dev/null +++ b/src/providers/legacyGoogleProvider.ts @@ -0,0 +1,59 @@ +import AbstractProvider, { + EndpointArgument, + LatLng, + ParseArgument, + SearchResult, +} from './provider'; + +export interface RequestResult { + results: RawResult[]; + status: string; +} + +export interface RawResult { + address_components: { + long_name: string; + short_name: string; + types: string[]; + }[]; + formatted_address: string; + geometry: { + location: LatLng; + location_type: string; + viewport: { + northeast: LatLng; + southwest: LatLng; + }; + }; + place_id: string; + plus_code: { + compound_code: string; + global_code: string; + }; + types: string[]; +} + +export default class LegacyGoogleProvider extends AbstractProvider< + RequestResult, + RawResult +> { + searchUrl = 'https://maps.googleapis.com/maps/api/geocode/json'; + + endpoint({ query }: EndpointArgument) { + const params = typeof query === 'string' ? { address: query } : query; + return this.getUrl(this.searchUrl, params); + } + + parse(result: ParseArgument): SearchResult[] { + return result.data.results.map((r) => ({ + x: r.geometry.location.lng, + y: r.geometry.location.lat, + label: r.formatted_address, + bounds: [ + [r.geometry.viewport.southwest.lat, r.geometry.viewport.southwest.lng], // s, w + [r.geometry.viewport.northeast.lat, r.geometry.viewport.northeast.lng], // n, e + ], + raw: r, + })); + } +}