File Coverage

blib/lib/Geo/Coder/GeoApify.pm
Criterion Covered Total %
statement 86 121 71.0
branch 22 42 52.3
condition 12 28 42.8
subroutine 15 16 93.7
pod 4 4 100.0
total 139 211 65.8


line stmt bran cond sub pod time code
1             package Geo::Coder::GeoApify;
2              
3 4     4   927179 use strict;
  4         9  
  4         131  
4 4     4   16 use warnings;
  4         6  
  4         220  
5              
6 4     4   16 use Carp;
  4         8  
  4         212  
7 4     4   1786 use CHI;
  4         320399  
  4         170  
8 4     4   2278 use Encode;
  4         62258  
  4         416  
9 4     4   39 use JSON::MaybeXS;
  4         28  
  4         248  
10 4     4   1233 use HTTP::Request;
  4         52004  
  4         148  
11 4     4   1869 use LWP::UserAgent;
  4         66018  
  4         157  
12 4     4   1834 use LWP::Protocol::https;
  4         584987  
  4         253  
13 4     4   2503 use Params::Get;
  4         62061  
  4         257  
14 4     4   39 use Time::HiRes;
  4         9  
  4         96  
15 4     4   214 use URI;
  4         11  
  4         6609  
16              
17             =head1 NAME
18              
19             Geo::Coder::GeoApify - Provides a Geo-Coding functionality using L
20              
21             =head1 VERSION
22              
23             Version 0.02
24              
25             =cut
26              
27             our $VERSION = '0.02';
28              
29             =head1 SYNOPSIS
30              
31             use Geo::Coder::GeoApify;
32              
33             my $geo_coder = Geo::Coder::GeoApify->new(apiKey => $ENV{'GEOAPIFY_KEY'});
34             my $location = $geo_coder->geocode(location => '10 Downing St., London, UK');
35              
36             =head1 DESCRIPTION
37              
38             Geo::Coder::GeoApify provides an interface to https://www.geoapify.com/maps-api/,
39             a free Geo-Coding database covering many countries.
40              
41             =over 4
42              
43             =item * Caching
44              
45             Identical geocode requests are cached (using L or a user-supplied caching object),
46             reducing the number of HTTP requests to the API and speeding up repeated queries.
47              
48             This module leverages L for caching geocoding responses.
49             When a geocode request is made,
50             a cache key is constructed from the request.
51             If a cached response exists,
52             it is returned immediately,
53             avoiding unnecessary API calls.
54              
55             =item * Rate-Limiting
56              
57             A minimum interval between successive API calls can be enforced to ensure that the API is not overwhelmed and to comply with any request throttling requirements.
58              
59             Rate-limiting is implemented using L.
60             A minimum interval between API
61             calls can be specified via the C parameter in the constructor.
62             Before making an API call,
63             the module checks how much time has elapsed since the
64             last request and,
65             if necessary,
66             sleeps for the remaining time.
67              
68             =back
69              
70             =head1 METHODS
71              
72             =head2 new
73              
74             $geo_coder = Geo::Coder::GeoApify->new(apiKey => $ENV{'GEOAPIFY_KEY'});
75              
76             Creates a new C object with the provided apiKey.
77              
78             It takes several optional parameters:
79              
80             =over 4
81              
82             =item * C
83              
84             A caching object.
85             If not provided,
86             an in-memory cache is created with a default expiration of one hour.
87              
88             =item * C
89              
90             The API host endpoint.
91             Defaults to L.
92              
93             =item * C
94              
95             Minimum number of seconds to wait between API requests.
96             Defaults to C<0> (no delay).
97             Use this option to enforce rate-limiting.
98              
99             =item * C
100              
101             An object to use for HTTP requests.
102             If not provided, a default user agent is created.
103              
104             =back
105              
106             =cut
107              
108             sub new
109             {
110 10     10 1 1144350 my $class = shift;
111              
112             # Handle hash or hashref arguments
113 10 100       67 my %args = (ref($_[0]) eq 'HASH') ? %{$_[0]} : @_;
  6         25  
114              
115             # Ensure the correct instantiation method is used
116 10 100       49 unless (defined $class) {
117 1         128 carp(__PACKAGE__, ' Use ->new() not ::new() to instantiate');
118 1         960 return;
119             }
120              
121             # If $class is an object, clone it with new arguments
122 9 100       29 return bless { %{$class}, %args }, ref($class) if ref($class);
  1         10  
123              
124             # Validate that the apiKey is provided and is a scalar
125 8         48 my $apiKey = $args{'apiKey'};
126 8 100 100     66 unless (defined $apiKey && !ref($apiKey)) {
127 2 100       436 carp(__PACKAGE__, defined $apiKey ? ' apiKey must be a scalar' : ' apiKey not given');
128 2         940 return;
129             }
130              
131             # Set up user agent (ua) if not provided
132 6   66     50 my $ua = $args{'ua'} // LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");
133 6         4444 $ua->default_header(accept_encoding => 'gzip,deflate');
134 6         602 $ua->env_proxy(1);
135              
136             # Disable SSL verification if the host is not defined (not recommended in production)
137 6 100       37186 $ua->ssl_opts(verify_hostname => 0) unless defined $args{'host'};
138              
139             # Set host, defaulting to 'api.geoapify.com/v1/geocode'
140 6   100     212 my $host = $args{'host'} // 'api.geoapify.com/v1/geocode';
141              
142             # Set up caching (default to an in-memory cache if none provided)
143 6   66     58 my $cache = $args{cache} || CHI->new(
144             driver => 'Memory',
145             global => 1,
146             expires_in => '1 day',
147             );
148              
149             # Set up rate-limiting: minimum interval between requests (in seconds)
150 6   100     294467 my $min_interval = $args{min_interval} || 0; # default: no delay
151              
152             # Return the blessed object
153 6         161 return bless {
154             apiKey => $apiKey,
155             cache => $cache,
156             host => $host,
157             min_interval => $min_interval,
158             last_request => 0, # Initialize last_request timestamp
159             ua => $ua,
160             %args
161             }, $class;
162             }
163              
164             =head2 geocode
165              
166             $location = $geo_coder->geocode(location => $location);
167              
168             print 'Latitude: ', $location->{'features'}[0]{'geometry'}{'coordinates'}[1], "\n";
169             print 'Longitude: ', $location->{'features'}[0]{'geometry'}{'coordinates'}[0], "\n";
170              
171             @locations = $geo_coder->geocode('Portland, USA');
172             print 'There are Portlands in ', join (', ', map { $_->{'state'} } @locations), "\n";
173              
174             =cut
175              
176             sub geocode
177             {
178 5     5 1 5080 my $self = shift;
179 5         68 my $params = Params::Get::get_params('location', @_);
180              
181             # Ensure location is provided
182             my $location = $params->{location}
183 5 50       231 or Carp::croak('Usage: geocode(location => $location)');
184              
185             # Fail when the input is just a set of numbers
186 5 50       44 if($location !~ /\D/) {
187 0         0 Carp::croak('Usage: ', __PACKAGE__, ": invalid input to geocode(), $location");
188 0         0 return;
189             }
190              
191             # Encode location if it's in UTF-8
192 5 50       29 $location = Encode::encode_utf8($location) if Encode::is_utf8($location);
193              
194             # Create URI for the API request
195 5         64 my $uri = URI->new("https://$self->{host}/search");
196              
197             # Handle potential confusion between England and New England
198 5         27871 $location =~ s/(.+),\s*England$/$1, United Kingdom/i;
199              
200             # Replace spaces with plus signs for URL encoding
201 5         49 $location =~ s/\s/+/g;
202              
203             # Set query parameters
204 5         72 $uri->query_form('text' => $location, 'apiKey' => $self->{'apiKey'});
205 5         1082 my $url = $uri->as_string();
206              
207             # Create a cache key based on the location (might want to use a stronger hash function if needed)
208 5         32 my $cache_key = "apify:$location";
209 5 100       55 if(my $cached = $self->{cache}->get($cache_key)) {
210 2         369 return $cached;
211             }
212              
213             # Enforce rate-limiting: ensure at least min_interval seconds between requests.
214 3         410 my $now = time();
215 3         13 my $elapsed = $now - $self->{last_request};
216 3 100       14 if($elapsed < $self->{min_interval}) {
217 1         1000220 Time::HiRes::sleep($self->{min_interval} - $elapsed);
218             }
219              
220             # Send the request and handle response
221 3         45 my $res = $self->{ua}->get($url);
222              
223             # Update last_request timestamp
224 3         5483 $self->{'last_request'} = time();
225              
226 3 50       17 if($res->is_error()) {
227 0         0 Carp::carp("API returned error on $url: ", $res->status_line());
228 0         0 return {};
229             }
230              
231             # Decode the JSON response
232 3         78 my $json = JSON::MaybeXS->new->utf8();
233 3         72 my $rc;
234 3         9 eval {
235 3         67 $rc = $json->decode($res->decoded_content());
236             };
237 3 50 33     909 if($@ || !defined $rc) {
238 0   0     0 Carp::carp("$url: Failed to decode JSON - ", $@ || $res->content());
239 0         0 return {};
240             }
241              
242             # Cache the result before returning it
243 3         35 $self->{'cache'}->set($cache_key, $rc);
244              
245 3         1594 return $rc;
246             }
247              
248             =head2 ua
249              
250             Accessor method to get and set UserAgent object used internally. You
251             can call I for example, to get the proxy information from
252             environment variables:
253              
254             $geo_coder->ua()->env_proxy(1);
255              
256             You can also set your own User-Agent object:
257              
258             use LWP::UserAgent::Throttled;
259              
260             my $ua = LWP::UserAgent::Throttled->new();
261             $ua->throttle({ 'api.geoapify.com' => 5 });
262             $ua->env_proxy(1);
263             $geo_coder = Geo::Coder::GeoApify->new({ ua => $ua, apiKey => $ENV{'GEOAPIFY_KEY'} });
264              
265             =cut
266              
267             sub ua
268             {
269 1     1 1 6 my $self = shift;
270              
271             # Update 'ua' if an argument is provided
272 1 50       6 $self->{ua} = shift if @_;
273              
274             # Return the 'ua' value
275 1         17 return $self->{ua};
276             }
277              
278             =head2 reverse_geocode
279              
280             my $address = $geo_coder->reverse_geocode(lat => 37.778907, lon => -122.39732);
281             print 'City: ', $address->{features}[0]->{'properties'}{'city'}, "\n";
282              
283             Similar to geocode except it expects a latitude,longitude pair.
284              
285             =cut
286              
287             sub reverse_geocode
288             {
289 0     0 1   my $self = shift;
290 0           my $params = Params::Get::get_params(undef, @_);
291              
292             # Validate latitude and longitude
293 0 0         my $lat = $params->{lat} or Carp::carp('Missing latitude (lat)');
294 0 0         my $lon = $params->{lon} or Carp::carp('Missing longitude (lon)');
295              
296 0 0 0       return {} unless $lat && $lon; # Return early if lat or lon is missing
297              
298             # Build URI for the API request
299 0           my $uri = URI->new("https://$self->{host}/reverse");
300             $uri->query_form(
301             'lat' => $lat,
302             'lon' => $lon,
303 0           'apiKey' => $self->{'apiKey'}
304             );
305 0           my $url = $uri->as_string();
306              
307             # Create a cache key based on the location (might want to use a stronger hash function if needed)
308 0           my $cache_key = "apify:reverse:$lat:$lon";
309 0 0         if(my $cached = $self->{cache}->get($cache_key)) {
310 0           return $cached;
311             }
312              
313             # Enforce rate-limiting: ensure at least min_interval seconds between requests.
314 0           my $now = time();
315 0           my $elapsed = $now - $self->{last_request};
316 0 0         if($elapsed < $self->{min_interval}) {
317 0           Time::HiRes::sleep($self->{min_interval} - $elapsed);
318             }
319              
320             # Send request to the API
321 0           my $res = $self->{ua}->get($url);
322              
323             # Update last_request timestamp
324 0           $self->{'last_request'} = time();
325              
326             # Handle API errors
327 0 0         if($res->is_error) {
328 0           Carp::carp("API returned error on $url: ", $res->status_line());
329 0           return {};
330             }
331              
332             # Decode the JSON response
333 0           my $json = JSON::MaybeXS->new->utf8();
334 0           my $rc;
335 0           eval {
336 0           $rc = $json->decode($res->decoded_content());
337             };
338              
339             # Handle JSON decoding errors
340 0 0 0       if($@ || !defined $rc) {
341 0   0       Carp::carp("$url: Failed to decode JSON - ", $@ || $res->content());
342 0           return {};
343             }
344              
345             # Cache the result before returning it
346 0           $self->{'cache'}->set($cache_key, $rc);
347              
348 0           return $rc;
349             }
350              
351             =head1 AUTHOR
352              
353             Nigel Horne, C<< >>
354              
355             This library is free software; you can redistribute it and/or modify
356             it under the same terms as Perl itself.
357              
358             Lots of thanks to the folks at geoapify.com
359              
360             =head1 SEE ALSO
361              
362             L, L
363              
364             =head1 LICENSE AND COPYRIGHT
365              
366             Copyright 2024-2025 Nigel Horne.
367              
368             This program is released under the following licence: GPL2
369              
370             =cut
371              
372             1;