Completed
Push — master ( cf9367...8a253f )
by Tobias
01:27
created

GoogleMapsPlaces.php (2 issues)

Labels
Severity
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the Geocoder package.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT License
11
 */
12
13
namespace Geocoder\Provider\GoogleMapsPlaces;
14
15
use Geocoder\Collection;
16
use Geocoder\Exception\InvalidArgument;
17
use Geocoder\Exception\InvalidCredentials;
18
use Geocoder\Exception\InvalidServerResponse;
19
use Geocoder\Exception\QuotaExceeded;
20
use Geocoder\Exception\UnsupportedOperation;
21
use Geocoder\Http\Provider\AbstractHttpProvider;
22
use Geocoder\Model\AddressBuilder;
23
use Geocoder\Model\AddressCollection;
24
use Geocoder\Provider\GoogleMapsPlaces\Model\GooglePlace;
25
use Geocoder\Provider\GoogleMapsPlaces\Model\OpeningHours;
26
use Geocoder\Provider\GoogleMapsPlaces\Model\Photo;
27
use Geocoder\Provider\GoogleMapsPlaces\Model\PlusCode;
28
use Geocoder\Provider\Provider;
29
use Geocoder\Query\GeocodeQuery;
30
use Geocoder\Query\Query;
31
use Geocoder\Query\ReverseQuery;
32
use Http\Client\HttpClient;
33
use stdClass;
34
35
/**
36
 * @author atymic <[email protected]>
37
 */
38
final class GoogleMapsPlaces extends AbstractHttpProvider implements Provider
39
{
40
    /**
41
     * @var string
42
     */
43
    const SEARCH_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/place/textsearch/json';
44
45
    /**
46
     * @var string
47
     */
48
    const FIND_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/place/findplacefromtext/json';
49
50
    /**
51
     * @var string
52
     */
53
    const NEARBY_ENDPOINT_URL_SSL = 'https://maps.googleapis.com/maps/api/place/nearbysearch/json';
54
55
    /**
56
     * @var string
57
     */
58
    const GEOCODE_MODE_FIND = 'find';
59
60
    /**
61
     * @var string
62
     */
63
    const GEOCODE_MODE_SEARCH = 'search';
64
65
    /**
66
     * @var string
67
     */
68
    const DEFAULT_GEOCODE_MODE = self::GEOCODE_MODE_FIND;
69
70
    /**
71
     * @var string
72
     */
73
    const DEFAULT_FIELDS = 'formatted_address,geometry,icon,name,permanently_closed,photos,place_id,plus_code,types';
74
75
    /**
76
     * @var string|null
77
     */
78
    private $apiKey;
79
80
    /**
81
     * @param HttpClient $client An HTTP adapter
82
     * @param string     $apiKey Google Maps Places API Key
83
     */
84
    public function __construct(HttpClient $client, string $apiKey)
85
    {
86
        parent::__construct($client);
87
88
        $this->apiKey = $apiKey;
89
    }
90
91
    /**
92
     * @param GeocodeQuery $query
93
     *
94
     * @return Collection
95
     *
96
     * @throws UnsupportedOperation
97
     * @throws InvalidArgument
98
     */
99
    public function geocodeQuery(GeocodeQuery $query): Collection
100
    {
101
        if (filter_var($query->getText(), FILTER_VALIDATE_IP)) {
102
            throw new UnsupportedOperation('The GoogleMapsPlaces provider does not support IP addresses');
103
        }
104
105
        if (self::GEOCODE_MODE_FIND === $query->getData('mode', self::DEFAULT_GEOCODE_MODE)) {
106
            return $this->fetchUrl(self::FIND_ENDPOINT_URL_SSL, $this->buildFindPlaceQuery($query));
107
        }
108
109
        if (self::GEOCODE_MODE_SEARCH === $query->getData('mode', self::DEFAULT_GEOCODE_MODE)) {
110
            return $this->fetchUrl(self::SEARCH_ENDPOINT_URL_SSL, $this->buildPlaceSearchQuery($query));
111
        }
112
113
        throw new InvalidArgument('Mode must be one of `%s, %s`', self::GEOCODE_MODE_FIND, self::GEOCODE_MODE_SEARCH);
0 ignored issues
show
self::GEOCODE_MODE_SEARCH of type string is incompatible with the type Throwable|null expected by parameter $previous of Geocoder\Exception\InvalidArgument::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

113
        throw new InvalidArgument('Mode must be one of `%s, %s`', self::GEOCODE_MODE_FIND, /** @scrutinizer ignore-type */ self::GEOCODE_MODE_SEARCH);
Loading history...
self::GEOCODE_MODE_FIND of type string is incompatible with the type integer expected by parameter $code of Geocoder\Exception\InvalidArgument::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

113
        throw new InvalidArgument('Mode must be one of `%s, %s`', /** @scrutinizer ignore-type */ self::GEOCODE_MODE_FIND, self::GEOCODE_MODE_SEARCH);
Loading history...
114
    }
115
116
    /**
117
     * @param ReverseQuery $query
118
     *
119
     * @return Collection
120
     *
121
     * @throws InvalidArgument
122
     */
123
    public function reverseQuery(ReverseQuery $query): Collection
124
    {
125
        return $this->fetchUrl(self::SEARCH_ENDPOINT_URL_SSL, $this->buildNearbySearchQuery($query));
126
    }
127
128
    /**
129
     * {@inheritdoc}
130
     */
131
    public function getName(): string
132
    {
133
        return 'google_maps_places';
134
    }
135
136
    /**
137
     * Build query for the find place API.
138
     *
139
     * @param GeocodeQuery $geocodeQuery
140
     *
141
     * @return array
142
     */
143
    private function buildFindPlaceQuery(GeocodeQuery $geocodeQuery): array
144
    {
145
        $query = [
146
            'input' => $geocodeQuery->getText(),
147
            'inputtype' => 'textquery',
148
            'fields' => self::DEFAULT_FIELDS,
149
        ];
150
151
        if (null !== $geocodeQuery->getLocale()) {
152
            $query['language'] = $geocodeQuery->getLocale();
153
        }
154
155
        // If query has bounds, set location bias to those bounds
156
        if (null !== $bounds = $geocodeQuery->getBounds()) {
157
            $query['locationbias'] = sprintf(
158
                'rectangle:%s,%s|%s,%s',
159
                $bounds->getSouth(),
160
                $bounds->getWest(),
161
                $bounds->getNorth(),
162
                $bounds->getEast()
163
            );
164
        }
165
166
        if (null !== $geocodeQuery->getData('fields')) {
167
            $query['fields'] = $geocodeQuery->getData('fields');
168
        }
169
170
        return $query;
171
    }
172
173
    /**
174
     * Build query for the place search API.
175
     *
176
     * @param GeocodeQuery $geocodeQuery
177
     *
178
     * @return array
179
     */
180
    private function buildPlaceSearchQuery(GeocodeQuery $geocodeQuery): array
181
    {
182
        $query = [
183
            'query' => $geocodeQuery->getText(),
184
        ];
185
186
        if (null !== $geocodeQuery->getLocale()) {
187
            $query['language'] = $geocodeQuery->getLocale();
188
        }
189
190
        $query = $this->applyDataFromQuery($geocodeQuery, $query, [
191
            'region',
192
            'type',
193
            'opennow',
194
            'minprice',
195
            'maxprice',
196
        ]);
197
198
        if (null !== $geocodeQuery->getData('location') && null !== $geocodeQuery->getData('radius')) {
199
            $query['location'] = (string) $geocodeQuery->getData('location');
200
            $query['radius'] = (int) $geocodeQuery->getData('radius');
201
        }
202
203
        return $query;
204
    }
205
206
    /**
207
     * Build query for the nearby search api.
208
     *
209
     * @param ReverseQuery $reverseQuery
210
     *
211
     * @return array
212
     */
213
    private function buildNearbySearchQuery(ReverseQuery $reverseQuery): array
214
    {
215
        $query = [
216
            'location' => sprintf(
217
                '%s,%s',
218
                $reverseQuery->getCoordinates()->getLatitude(),
219
                $reverseQuery->getCoordinates()->getLongitude()
220
            ),
221
            'rankby' => 'distance',
222
        ];
223
224
        if (null !== $reverseQuery->getLocale()) {
225
            $query['language'] = $reverseQuery->getLocale();
226
        }
227
228
        $query = $this->applyDataFromQuery($reverseQuery, $query, [
229
            'keyword',
230
            'type',
231
            'name',
232
            'minprice',
233
            'maxprice',
234
            'name',
235
            'opennow',
236
        ]);
237
238
        $requiredParameters = array_filter(array_keys($query), function (string $key) {
239
            return in_array($key, ['keyword', 'type', 'name'], true);
240
        });
241
242
        if (1 !== count($requiredParameters)) {
243
            throw new InvalidArgument('One of `type`, `keyword`, `name` is required to be set in the Query data for Reverse Geocoding');
244
        }
245
246
        return $query;
247
    }
248
249
    /**
250
     * @param Query $query
251
     * @param array $request
252
     * @param array $keys
253
     *
254
     * @return array
255
     */
256
    private function applyDataFromQuery(Query $query, array $request, array $keys)
257
    {
258
        foreach ($keys as $key) {
259
            if (null === $query->getData($key)) {
260
                continue;
261
            }
262
263
            $request[$key] = $query->getData($key);
264
        }
265
266
        return $request;
267
    }
268
269
    /**
270
     * @param string $url
271
     * @param array  $query
272
     *
273
     * @return AddressCollection
274
     */
275
    private function fetchUrl(string $url, array $query): AddressCollection
276
    {
277
        $query['key'] = $this->apiKey;
278
279
        $url = sprintf('%s?%s', $url, http_build_query($query));
280
281
        $content = $this->getUrlContents($url);
282
        $json = $this->validateResponse($url, $content);
283
284
        if (empty($json->candidates) && empty($json->results) || 'OK' !== $json->status) {
285
            return new AddressCollection([]);
286
        }
287
288
        $results = [];
289
290
        $apiResults = isset($json->results) ? $json->results : $json->candidates;
291
292
        foreach ($apiResults as $result) {
293
            $builder = new AddressBuilder($this->getName());
294
            $this->parseCoordinates($builder, $result);
295
296
            if (isset($result->place_id)) {
297
                $builder->setValue('id', $result->place_id);
298
            }
299
300
            /** @var GooglePlace $address */
301
            $address = $builder->build(GooglePlace::class);
302
            $address = $address->withId($builder->getValue('id'));
303
304
            if (isset($result->name)) {
305
                $address = $address->withName($result->name);
306
            }
307
308
            if (isset($result->formatted_address)) {
309
                $address = $address->withFormattedAddress($result->formatted_address);
310
            }
311
312
            if (isset($result->types)) {
313
                $address = $address->withType($result->types);
314
            }
315
316
            if (isset($result->icon)) {
317
                $address = $address->withIcon($result->icon);
318
            }
319
320
            if (isset($result->plus_code)) {
321
                $address = $address->withPlusCode(new PlusCode(
322
                    $result->plus_code->global_code,
323
                    $result->plus_code->compound_code
324
                ));
325
            }
326
327
            if (isset($result->photos)) {
328
                $address = $address->withPhotos(Photo::getPhotosFromResult($result->photos));
329
            }
330
331
            if (isset($result->price_level)) {
332
                $address = $address->withPriceLevel($result->price_level);
333
            }
334
335
            if (isset($result->rating)) {
336
                $address = $address->withRating((float) $result->rating);
337
            }
338
339
            if (isset($result->formatted_phone_number)) {
340
                $address = $address->withFormattedPhoneNumber($result->formatted_phone_number);
341
            }
342
343
            if (isset($result->international_phone_number)) {
344
                $address = $address->withInternationalPhoneNumber($result->international_phone_number);
345
            }
346
347
            if (isset($result->website)) {
348
                $address = $address->withWebsite($result->website);
349
            }
350
351
            if (isset($result->opening_hours)) {
352
                $address = $address->withOpeningHours(OpeningHours::fromResult($result->opening_hours));
353
            }
354
355
            if (isset($result->permanently_closed)) {
356
                $address = $address->setPermanentlyClosed();
357
            }
358
359
            $results[] = $address;
360
        }
361
362
        return new AddressCollection($results);
363
    }
364
365
    /**
366
     * Decode the response content and validate it to make sure it does not have any errors.
367
     *
368
     * @param string $url
369
     * @param string $content
370
     *
371
     * @return \StdClass
372
     *
373
     * @throws InvalidCredentials
374
     * @throws InvalidServerResponse
375
     * @throws QuotaExceeded
376
     */
377
    private function validateResponse(string $url, $content): StdClass
378
    {
379
        $json = json_decode($content);
380
381
        // API error
382
        if (!isset($json)) {
383
            throw InvalidServerResponse::create($url);
384
        }
385
386
        if ('INVALID_REQUEST' === $json->status) {
387
            throw new InvalidArgument(sprintf('Invalid Request %s', $url));
388
        }
389
390
        if ('REQUEST_DENIED' === $json->status && 'The provided API key is invalid.' === $json->error_messages) {
391
            throw new InvalidCredentials(sprintf('API key is invalid %s', $url));
392
        }
393
394
        if ('REQUEST_DENIED' === $json->status) {
395
            throw new InvalidServerResponse(sprintf('API access denied. Request: %s - Message: %s', $url, $json->error_messages));
396
        }
397
398
        if ('OVER_QUERY_LIMIT' === $json->status) {
399
            throw new QuotaExceeded(sprintf('Daily quota exceeded %s', $url));
400
        }
401
402
        return $json;
403
    }
404
405
    /**
406
     * Parse coordinates and bounds.
407
     *
408
     * @param AddressBuilder $builder
409
     * @param StdClass       $result
410
     */
411
    private function parseCoordinates(AddressBuilder $builder, StdClass $result)
412
    {
413
        $coordinates = $result->geometry->location;
414
        $builder->setCoordinates($coordinates->lat, $coordinates->lng);
415
416
        if (isset($result->geometry->viewport)) {
417
            $builder->setBounds(
418
                $result->geometry->viewport->southwest->lat,
419
                $result->geometry->viewport->southwest->lng,
420
                $result->geometry->viewport->northeast->lat,
421
                $result->geometry->viewport->northeast->lng
422
            );
423
        }
424
    }
425
}
426