File Coverage

blib/lib/Geo/GeoNames.pm
Criterion Covered Total %
statement 120 160 75.0
branch 42 76 55.2
condition 23 43 53.4
subroutine 20 23 86.9
pod 7 7 100.0
total 212 309 68.6


line stmt bran cond sub pod time code
1             package Geo::GeoNames;
2             # use utf8;
3 7     7   1805741 use v5.10;
  7         28  
4 7     7   39 use strict;
  7         29  
  7         217  
5 7     7   37 use warnings;
  7         33  
  7         461  
6              
7 7     7   41 use Carp;
  7         16  
  7         654  
8 7     7   4550 use Mojo::UserAgent;
  7         4313041  
  7         77  
9 7     7   526 use Scalar::Util qw/blessed/;
  7         18  
  7         12014  
10              
11             =encoding utf8
12              
13             =head1 NAME
14              
15             Geo::GeoNames - Perform geographical queries using GeoNames Web Services
16              
17             =head1 VERSION
18              
19             Version 1.15
20              
21             =cut
22              
23             our $VERSION = '1.15';
24              
25             =head1 SYNOPSIS
26              
27             use Geo::GeoNames;
28             my $geo = Geo::GeoNames->new(username => $ENV{'GEONAME_USER'});
29              
30             # make a query based on placename
31             my $result = $geo->search(q => 'Fredrikstad', maxRows => 2);
32              
33             # print the first result
34             print ' Name: ', $result->[0]->{name}, "\n";
35             print ' Longitude: ', $result->[0]->{lng}, "\n";
36             print ' Latitude: ', $result->[0]->{lat}, "\n";
37              
38             # Make a query based on postcode
39             $result = $geo->postalcode_search(
40             postalcode => '1630', maxRows => 3, style => 'FULL'
41             );
42              
43             =head1 DESCRIPTION
44              
45             Before you start, get a free GeoNames account and enable it for
46             access to the free web service:
47              
48             =over 4
49              
50             =item * Get an account
51              
52             Go to L
53              
54             =item * Respond to the email
55              
56             =item * Login and enable your account for free access
57              
58             L
59              
60             =back
61              
62             Provides a perl interface to the webservices found at
63             L. That is, given a given placename or
64             postalcode, the module will look it up and return more information
65             (longitude, latitude, etc) for the given placename or postalcode.
66             Wikipedia lookups are also supported. If more than one match is found,
67             a list of locations will be returned.
68              
69             =cut
70              
71             # use vars qw($DEBUG $CACHE);
72              
73             our %searches = (
74             cities => 'cities?',
75             country_code => 'countrycode?type=xml&',
76             country_info => 'countryInfo?',
77             earthquakes => 'earthquakesJSON?',
78             find_nearby_placename => 'findNearbyPlaceName?',
79             find_nearby_postalcodes => 'findNearbyPostalCodes?',
80             find_nearby_streets => 'findNearbyStreets?',
81             find_nearby_weather => 'findNearByWeatherXML?',
82             find_nearby_wikipedia => 'findNearbyWikipedia?',
83             find_nearby_wikipedia_by_postalcode => 'findNearbyWikipedia?',
84             find_nearest_address => 'findNearestAddress?',
85             find_nearest_intersection => 'findNearestIntersection?',
86             postalcode_country_info => 'postalCodeCountryInfo?',
87             postalcode_search => 'postalCodeSearch?',
88             search => 'search?',
89             wikipedia_bounding_box => 'wikipediaBoundingBox?',
90             wikipedia_search => 'wikipediaSearch?',
91             get => 'get?',
92             hierarchy => 'hierarchy?',
93             children => 'children?',
94             );
95              
96             # r = required
97             # o = optional
98             # rc = required - only one of the fields marked with rc is allowed. At least one must be present
99             # om = optional, multiple entries allowed
100             # d = deprecated - will be removed in later versions
101             our %valid_parameters = (
102             search => {
103             'q' => 'rc',
104             name => 'rc',
105             name_equals => 'rc',
106             maxRows => 'o',
107             startRow => 'o',
108             country => 'om',
109             continentCode => 'o',
110             adminCode1 => 'o',
111             adminCode2 => 'o',
112             adminCode3 => 'o',
113             fclass => 'omd',
114             featureClass => 'om',
115             featureCode => 'om',
116             lang => 'o',
117             type => 'o',
118             style => 'o',
119             isNameRequired => 'o',
120             tag => 'o',
121             username => 'r',
122             name_startsWith => 'o', # TODO - should this be rc?
123             countryBias => 'o',
124             cities => 'om',
125             operator => 'o',
126             searchlang => 'o',
127             charset => 'o',
128             fuzzy => 'o',
129             north => 'o',
130             west => 'o',
131             east => 'o',
132             south => 'o',
133             orderby => 'o',
134             },
135             postalcode_search => {
136             postalcode => 'rc',
137             placename => 'rc',
138             country => 'o',
139             maxRows => 'o',
140             style => 'o',
141             username => 'r',
142             },
143             find_nearby_postalcodes => {
144             lat => 'r',
145             lng => 'r',
146             radius => 'o',
147             maxRows => 'o',
148             style => 'o',
149             country => 'o',
150             username => 'r',
151             },
152             postalcode_country_info => {
153             username => 'r',
154             },
155             find_nearby_placename => {
156             lat => 'r',
157             lng => 'r',
158             radius => 'o',
159             style => 'o',
160             maxRows => 'o',
161             lang => 'o',
162             cities => 'o',
163             username => 'r',
164             },
165             find_nearest_address => {
166             lat => 'r',
167             lng => 'r',
168             username => 'r',
169             },
170             find_nearest_intersection => {
171             lat => 'r',
172             lng => 'r',
173             username => 'r',
174             },
175             find_nearby_streets => {
176             lat => 'r',
177             lng => 'r',
178             username => 'r',
179             },
180             find_nearby_wikipedia => {
181             lang => 'o',
182             lat => 'r',
183             lng => 'r',
184             radius => 'o',
185             maxRows => 'o',
186             country => 'o',
187             username => 'r',
188             },
189             find_nearby_wikipedia_by_postalcode => {
190             postalcode => 'r',
191             country => 'r',
192             radius => 'o',
193             maxRows => 'o',
194             username => 'r',
195             },
196             wikipedia_search => {
197             'q' => 'r',
198             lang => 'o',
199             title => 'o',
200             maxRows => 'o',
201             username => 'r',
202             },
203             wikipedia_bounding_box => {
204             south => 'r',
205             north => 'r',
206             east => 'r',
207             west => 'r',
208             lang => 'o',
209             maxRows => 'o',
210             username => 'r',
211             },
212             country_info => {
213             country => 'o',
214             lang => 'o',
215             username => 'r',
216             },
217             country_code => {
218             lat => 'r',
219             lng => 'r',
220             lang => 'o',
221             radius => 'o',
222             username => 'r',
223             },
224             find_nearby_weather => {
225             lat => 'r',
226             lng => 'r',
227             username => 'r',
228             },
229             cities => {
230             north => 'r',
231             south => 'r',
232             east => 'r',
233             west => 'r',
234             lang => 'o',
235             maxRows => 'o',
236             username => 'r',
237             },
238             earthquakes => {
239             north => 'r',
240             south => 'r',
241             east => 'r',
242             west => 'r',
243             date => 'o',
244             minMagnitude => 'o',
245             maxRows => 'o',
246             username => 'r',
247             },
248             get => {
249             geonameId => 'r',
250             lang => 'o',
251             style => 'o',
252             username => 'r',
253             },
254             hierarchy => {
255             geonameId => 'r',
256             username => 'r',
257             style => 'o',
258             },
259             children => {
260             geonameId => 'r',
261             username => 'r',
262             style => 'o',
263             },
264             );
265              
266             sub new {
267 10     10 1 431518 my $class = shift;
268 10 50       113 my %args = (ref($_[0]) eq 'HASH') ? %{$_[0]} : @_;
  0         0  
269              
270 10 50       55 if(!defined($class)) {
    50          
271             # Using Geo::GeoNames->new(), not Geo::GeoNames::new()
272             # carp(__PACKAGE__, ' use ->new() not ::new() to instantiate');
273             # return;
274              
275             # FIXME: this only works when no arguments are given
276 0         0 $class = __PACKAGE__;
277             } elsif(ref($class)) {
278             # clone the given object
279 0         0 return bless { %{$class}, %args }, ref($class);
  0         0  
280             }
281              
282 10 100       478 croak <<"HERE" unless length $args{username};
283             You must specify a GeoNames username to use Geo::GeoNames.
284             See http://www.geonames.org/export/web-services.html
285             HERE
286              
287 8         45 my $self = bless { _functions => \%searches, %args }, $class;
288              
289             # $self->username( $args{username} );
290 8   66     50 $self->url( $args{url} // $self->default_url() );
291              
292             croak 'Illegal ua object, needs either a Mojo::UserAgent or an LWP::UserAgent derived object'
293 8 100 100     694 if exists $args{ua} && !(ref $args{ua} && blessed($args{ua}) && ( $args{ua}->isa('Mojo::UserAgent') || $args{ua}->isa('LWP::UserAgent') ) );
      100        
294 5   66     69 $self->ua($args{ua} || $self->default_ua );
295              
296             # (exists($args{debug})) ? $DEBUG = $args{debug} : 0;
297             # (exists($args{cache})) ? $CACHE = $args{cache} : 0;
298             # $self->{_functions} = \%searches;
299              
300 5         45 return $self;
301             }
302              
303             sub username {
304 1     1 1 2 my( $self, $username ) = @_;
305              
306 1 50       10 $self->{username} = $username if @_ == 2;
307              
308 1         6 $self->{username};
309             }
310              
311             =head2 ua
312              
313             Accessor method to get and set UserAgent object used internally. You
314             can call I for example, to get the proxy information from
315             environment variables:
316              
317             $geo_coder->ua()->env_proxy(1);
318              
319             You can also set your own User-Agent object:
320              
321             use LWP::UserAgent::Throttled;
322             $geo_coder->ua(LWP::UserAgent::Throttled->new());
323              
324             =cut
325              
326             sub ua {
327 5     5 1 31 my $self = shift;
328 5 50       19 if (@_) {
329 5         21 $self->{ua} = shift;
330             }
331 5         15 $self->{ua};
332             }
333              
334             sub default_ua
335             {
336 3     3 1 42 my $ua = Mojo::UserAgent->new();
337 3     0   103 $ua->on( error => sub { carp "Can't get request" } );
  0         0  
338 3         78 return $ua;
339             }
340              
341 7     7 1 37 sub default_url { 'http://api.geonames.org' }
342              
343             sub url {
344 9     9 1 26 my( $self, $url ) = @_;
345              
346 9 100       50 $self->{url} = $url if @_ == 2;
347              
348 9         21 $self->{url};
349             }
350              
351             sub _build_request_url {
352 1     1   4 my( $self, $request, @args ) = @_;
353 1         5 my $hash = { @args, username => $self->username };
354 1         4 my $request_url = $self->url . '/' . $searches{$request};
355              
356             # check to see that mandatory arguments are present
357 1         2 my $conditional_mandatory_flag = 0;
358 1         2 my $conditional_mandatory_required = 0;
359 1         2 foreach my $arg (keys %{$valid_parameters{$request}}) {
  1         9  
360 31         60 my $flags = $valid_parameters{$request}->{$arg};
361 31 50 66     69 if($flags =~ /d/ && exists($hash->{$arg})) {
362 0         0 carp("Argument $arg is deprecated.");
363             }
364 31         55 $flags =~ s/d//g;
365 31 50 66     76 if($flags eq 'r' && !exists($hash->{$arg})) {
366 0         0 carp("Mandatory argument $arg is missing!");
367             }
368 31 50 100     115 if($flags !~ /m/ && exists($hash->{$arg}) && ref($hash->{$arg})) {
      66        
369 0         0 carp("Argument $arg cannot have multiple values.");
370             }
371 31 100       90 if($flags eq 'rc') {
372 3         6 $conditional_mandatory_required = 1;
373 3 100       26 if(exists($hash->{$arg})) {
374 1         2 $conditional_mandatory_flag++;
375             }
376             }
377             }
378              
379 1 50 33     10 if($conditional_mandatory_required == 1 && $conditional_mandatory_flag != 1) {
380 0         0 carp("Invalid number of mandatory arguments (there can be only one)");
381             }
382 1         6 foreach my $key (sort keys(%$hash)) {
383 2 50       6 carp("Invalid argument $key") if(!defined($valid_parameters{$request}->{$key}));
384 2 50       10 my @vals = ref($hash->{$key}) ? @{$hash->{$key}} : $hash->{$key};
  0         0  
385 7     7   75 no warnings 'uninitialized';
  7         12  
  7         12624  
386 2         6 $request_url .= join('', map { "$key=$_&" } sort @vals );
  2         13  
387             }
388              
389 1         3 chop($request_url); # lose the trailing &
390 1         5 return $request_url;
391             }
392              
393             sub _parse_xml_result {
394 3     3   204012 require XML::Simple;
395 3         27732 my( $self, $geonamesresponse, $single_result ) = @_;
396 3         9 my @result;
397 3         28 my $xmlsimple = XML::Simple->new;
398 3         1122 my $xml = $xmlsimple->XMLin( $geonamesresponse, KeyAttr => [], ForceArray => 1 );
399              
400 3 100       189855 if ($xml->{'status'}) {
401 1         12 carp 'GeoNames error: ', $xml->{'status'}->[0]->{message};
402 1         24 return [];
403             }
404              
405 2 100       11 $xml = { geoname => [ $xml ], totalResultsCount => '1' } if $single_result;
406              
407 2         5 my $i = 0;
408 2         6 foreach my $element (keys %{$xml}) {
  2         12  
409 5 100       18 next if (ref($xml->{$element}) ne 'ARRAY');
410 3         7 foreach my $list (@{$xml->{$element}}) {
  3         8  
411 3 100       10 next if (ref($list) ne 'HASH');
412 2         5 foreach my $attribute (%{$list}) {
  2         22  
413 96 100       294 next if !defined($list->{$attribute}->[0]);
414 48 100       65 $result[$i]->{$attribute} = (scalar @{$list->{$attribute}} == 1 ? $list->{$attribute}->[0] : $list->{$attribute});
  48         173  
415             }
416 2         13 $i++;
417             }
418             }
419 2         69 return \@result;
420             }
421              
422             sub _parse_json_result {
423 1     1   784 require JSON::MaybeXS;
424 1         5257 my( $self, $geonamesresponse ) = @_;
425              
426 1         9 return JSON::MaybeXS->new->utf8->decode($geonamesresponse);
427             }
428              
429             sub _parse_text_result {
430 0     0   0 my( $self, $geonamesresponse ) = @_;
431 0         0 my @result;
432 0         0 $result[0]->{Result} = $geonamesresponse;
433 0         0 return \@result;
434             }
435              
436             sub _request {
437 1     1   4 my ($self, $request_url) = @_;
438              
439 1 50       4 if($self->{'logger'}) {
440 0         0 $self->{'logger'}->trace('> ', ref($self), ": _request: $request_url");
441             }
442 1         5 my $res = $self->{ua}->get($request_url);
443              
444             # Handle Mojo::UserAgent response
445 1 50       494463 if($res->can('res')) {
446 1         14 my $response = $res->res();
447 1 50       10 unless($response->is_success) {
448 0   0     0 my $code = $response->code() || 'unknown';
449 0   0     0 my $message = $response->message() || 'HTTP request failed';
450 0         0 carp "HTTP request failed: $code $message for URL: $request_url";
451 0         0 return undef;
452             }
453 1         24 return $response;
454             }
455              
456             # Handle LWP::UserAgent response
457 0 0       0 unless ($res->is_success()) {
458 0   0     0 my $code = $res->code() || 'unknown';
459 0   0     0 my $message = $res->message() || 'HTTP request failed';
460 0         0 carp "HTTP request failed: $code $message for URL: $request_url";
461 0         0 return undef;
462             }
463              
464 0 0       0 return $res->can('res') ? $res->res() : $res;
465             }
466              
467             sub _do_search {
468 1     1   4 my( $self, $searchtype, @args ) = @_;
469              
470 1         16 my $request_url = $self->_build_request_url( $searchtype, @args );
471 1         4 my $response = $self->_request($request_url);
472              
473             # Return empty array if request failed
474 1 50       5 return [] unless defined $response;
475              
476             # Verify HTTP status code
477 1         5 my $status_code = $response->code();
478 1 50 33     12 unless ($status_code >= 200 && $status_code < 300) {
479 0         0 carp "HTTP error: received status code $status_code for URL: $request_url";
480 0         0 return [];
481             }
482              
483             # check mime-type to determine which parse method to use.
484             # we accept text/xml, text/plain (how do see if it is JSON or not?)
485 1   50     5 my $mime_type = $response->headers->content_type || '';
486              
487             # Extract just the base MIME type without parameters (e.g., charset)
488 1         23 my $base_mime_type = $mime_type;
489 1         8 $base_mime_type =~ s/;.*$//; # Remove everything after semicolon
490 1         8 $base_mime_type =~ s/^\s+|\s+$//g; # Trim whitespace
491              
492 1 50       26 my $body = $response->can('body') ? $response->body() : $response->content;
493              
494             # Check for XML response
495 1 50 33     29 if($base_mime_type eq 'text/xml' || $base_mime_type eq 'application/xml') {
496 1         6 return $self->_parse_xml_result( $body, $searchtype eq 'get' );
497             }
498              
499             # Check for JSON response
500 0 0       0 if($base_mime_type eq 'application/json') {
501             # a JSON object always start with a left-brace {
502             # according to http://json.org/
503 0 0       0 if( $body =~ m/\A\{/ ) {
504 0 0       0 if ($response->can('json')) {
505 0         0 return $response->json;
506             } else {
507 0         0 return $self->_parse_json_result( $body );
508             }
509             } else {
510 0         0 return $self->_parse_text_result( $body );
511             }
512             }
513              
514             # Unexpected MIME type
515 0 0       0 if($base_mime_type eq 'text/plain') {
    0          
516 0         0 carp "Unexpected mime type [text/plain]. Response body: ", substr($body, 0, 200);
517             } elsif($base_mime_type eq 'text/html') {
518 0         0 carp "Received HTML response instead of expected data format. This may indicate an error page or service unavailability.";
519             } else {
520 0         0 carp "Unsupported mime type [$mime_type]. Expected text/xml or application/json.";
521             }
522             }
523              
524             sub geocode {
525 0     0 1 0 my( $self, $q ) = @_;
526 0         0 $self->search( 'q' => $q );
527             }
528              
529             sub AUTOLOAD {
530 1     1   12 my $self = shift;
531             # my $type = ref($self) || croak "$self is not an object";
532 1 50       4 ref($self) || croak "$self is not an object";
533 1         2 my $name = our $AUTOLOAD;
534 1         8 $name =~ s/.*://;
535              
536 1 50       5 unless (exists $self->{_functions}->{$name}) {
537 0         0 croak "No such method '$AUTOLOAD'";
538             }
539              
540 1         4 return($self->_do_search($name, @_));
541             }
542              
543 8     8   9741 sub DESTROY { 1 }
544              
545             1;
546              
547             __END__