File Coverage

File:blib/lib/Geo/GeoNames.pm
Coverage:67.9%

linestmtbrancondsubtimecode
1package Geo::GeoNames;
2# use utf8;
3
8
8
676721
11
use v5.10;
4
8
8
8
16
6
73
use strict;
5
8
8
8
14
2
132
use warnings;
6
7
8
8
8
12
7
186
use Carp;
8
8
8
8
1876
1394967
25
use Mojo::UserAgent;
9
8
8
8
167
8
3762
use Scalar::Util qw/blessed/;
10
11=encoding utf8
12
13 - 21
=head1 NAME

Geo::GeoNames - Perform geographical queries using GeoNames Web Services

=head1 VERSION

Version 1.15

=cut
22
23our $VERSION = '1.15';
24
25 - 69
=head1 SYNOPSIS

        use Geo::GeoNames;
        my $geo = Geo::GeoNames->new(username => $ENV{'GEONAME_USER'});

        # make a query based on placename
        my $result = $geo->search(q => 'Fredrikstad', maxRows => 2);

        # print the first result
        print ' Name: ', $result->[0]->{name}, "\n";
        print ' Longitude: ', $result->[0]->{lng}, "\n";
        print ' Latitude: ', $result->[0]->{lat}, "\n";

        # Make a query based on postcode
        $result = $geo->postalcode_search(
                postalcode => '1630', maxRows => 3, style => 'FULL'
        );

=head1 DESCRIPTION

Before you start, get a free GeoNames account and enable it for
access to the free web service:

=over 4

=item * Get an account

Go to L<http://www.geonames.org/login>

=item * Respond to the email

=item * Login and enable your account for free access

L<http://www.geonames.org/enablefreewebservice>

=back

Provides a perl interface to the webservices found at
L<http://api.geonames.org>. That is, given a given placename or
postalcode, the module will look it up and return more information
(longitude, latitude, etc) for the given placename or postalcode.
Wikipedia lookups are also supported. If more than one match is found,
a list of locations will be returned.

=cut
70
71# use vars qw($DEBUG $CACHE);
72
73our %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
101our %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
266sub new {
267
10
111082
        my $class = shift;
268
10
0
20
0
        my %args = (ref($_[0]) eq 'HASH') ? %{$_[0]} : @_;
269
270
10
14
        if(!defined($class)) {
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
0
0
                return bless { %{$class}, %args }, ref($class);
280        }
281
282
10
134
        croak <<"HERE" unless length $args{username};
283You must specify a GeoNames username to use Geo::GeoNames.
284See http://www.geonames.org/export/web-services.html
285HERE
286
287
8
16
        my $self = bless { _functions => \%searches, %args }, $class;
288
289        # $self->username( $args{username} );
290
8
18
        $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
178
           if exists $args{ua} && !(ref $args{ua} && blessed($args{ua}) && ( $args{ua}->isa('Mojo::UserAgent') || $args{ua}->isa('LWP::UserAgent') ) );
294
5
20
        $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
9
        return $self;
301}
302
303sub username {
304
1
1
        my( $self, $username ) = @_;
305
306
1
1
        $self->{username} = $username if @_ == 2;
307
308
1
10
        $self->{username};
309}
310
311 - 324
=head2 ua

Accessor method to get and set UserAgent object used internally. You
can call I<env_proxy> for example, to get the proxy information from
environment variables:

    $geo_coder->ua()->env_proxy(1);

You can also set your own User-Agent object:

    use LWP::UserAgent::Throttled;
    $geo_coder->ua(LWP::UserAgent::Throttled->new());

=cut
325
326sub ua {
327
5
4
        my $self = shift;
328
5
4
        if (@_) {
329
5
6
                $self->{ua} = shift;
330        }
331
5
5
        $self->{ua};
332}
333
334sub default_ua
335{
336
3
9
        my $ua = Mojo::UserAgent->new();
337
3
0
15
0
        $ua->on( error => sub { carp "Can't get request" } );
338
3
16
        return $ua;
339}
340
341
7
11
sub default_url { 'http://api.geonames.org' }
342
343sub url {
344
9
8
        my( $self, $url ) = @_;
345
346
9
14
        $self->{url} = $url if @_ == 2;
347
348
9
8
        $self->{url};
349}
350
351sub _build_request_url {
352
1
1
        my( $self, $request, @args ) = @_;
353
1
3
        my $hash = { @args, username => $self->username };
354
1
1
        my $request_url = $self->url . '/' . $searches{$request};
355
356        # check to see that mandatory arguments are present
357
1
1
        my $conditional_mandatory_flag = 0;
358
1
1
        my $conditional_mandatory_required = 0;
359
1
1
0
2
        foreach my $arg (keys %{$valid_parameters{$request}}) {
360
31
17
                my $flags = $valid_parameters{$request}->{$arg};
361
31
21
                if($flags =~ /d/ && exists($hash->{$arg})) {
362
0
0
                        carp("Argument $arg is deprecated.");
363                        }
364
31
13
                $flags =~ s/d//g;
365
31
25
                if($flags eq 'r' && !exists($hash->{$arg})) {
366
0
0
                        carp("Mandatory argument $arg is missing!");
367                        }
368
31
29
                if($flags !~ /m/ && exists($hash->{$arg}) && ref($hash->{$arg})) {
369
0
0
                        carp("Argument $arg cannot have multiple values.");
370                        }
371
31
22
                if($flags eq 'rc') {
372
3
2
                        $conditional_mandatory_required = 1;
373
3
1
                        if(exists($hash->{$arg})) {
374
1
1
                                $conditional_mandatory_flag++;
375                                }
376                        }
377                }
378
379
1
2
        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
3
        foreach my $key (sort keys(%$hash)) {
383
2
2
                carp("Invalid argument $key") if(!defined($valid_parameters{$request}->{$key}));
384
2
0
3
0
                my @vals = ref($hash->{$key}) ? @{$hash->{$key}} : $hash->{$key};
385
8
8
8
24
8
4068
                no warnings 'uninitialized';
386
2
2
1
4
                $request_url .= join('', map { "$key=$_&" } sort @vals );
387                }
388
389
1
3
        chop($request_url); # lose the trailing &
390
1
1
        return $request_url;
391        }
392
393sub _parse_xml_result {
394
3
84898
        require XML::Simple;
395
3
7325
        my( $self, $geonamesresponse, $single_result ) = @_;
396
3
3
        my @result;
397
3
7
        my $xmlsimple = XML::Simple->new;
398
3
83
        my $xml = $xmlsimple->XMLin( $geonamesresponse, KeyAttr => [], ForceArray => 1 );
399
400
3
35230
        if ($xml->{'status'}) {
401
1
2
                carp 'GeoNames error: ', $xml->{'status'}->[0]->{message};
402
1
7
                return [];
403        }
404
405
2
8
        $xml = { geoname => [ $xml ], totalResultsCount => '1' } if $single_result;
406
407
2
2
        my $i = 0;
408
2
2
1
4
        foreach my $element (keys %{$xml}) {
409
5
4
                next if (ref($xml->{$element}) ne 'ARRAY');
410
3
3
2
3
                foreach my $list (@{$xml->{$element}}) {
411
3
3
                        next if (ref($list) ne 'HASH');
412
2
2
2
5
                        foreach my $attribute (%{$list}) {
413
96
87
                                next if !defined($list->{$attribute}->[0]);
414
48
48
16
55
                                $result[$i]->{$attribute} = (scalar @{$list->{$attribute}} == 1 ? $list->{$attribute}->[0] : $list->{$attribute});
415                                }
416
2
3
                        $i++;
417                        }
418                }
419
2
16
        return \@result;
420        }
421
422sub _parse_json_result {
423
1
26
        require JSON::MaybeXS;
424
1
1
        my( $self, $geonamesresponse ) = @_;
425
426
1
220
        return JSON::MaybeXS->new->utf8->decode($geonamesresponse);
427}
428
429sub _parse_text_result {
430
0
0
        my( $self, $geonamesresponse ) = @_;
431
0
0
        my @result;
432
0
0
        $result[0]->{Result} = $geonamesresponse;
433
0
0
        return \@result;
434}
435
436sub _request {
437
1
0
        my ($self, $request_url) = @_;
438
439
1
2
        if($self->{'logger'}) {
440
0
0
                $self->{'logger'}->trace('> ', ref($self), ": _request: $request_url");
441        }
442
1
1
        my $res = $self->{ua}->get($request_url);
443
444        # Handle Mojo::UserAgent response
445
1
610777
        if($res->can('res')) {
446
1
5
                my $response = $res->res();
447
1
2
                unless($response->is_success) {
448
0
0
                        my $code = $response->code() || 'unknown';
449
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
7
                return $response;
454        }
455
456        # Handle LWP::UserAgent response
457
0
0
        unless ($res->is_success()) {
458
0
0
                my $code = $res->code() || 'unknown';
459
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
        return $res->can('res') ? $res->res() : $res;
465}
466
467sub _do_search {
468
1
1
        my( $self, $searchtype, @args ) = @_;
469
470
1
1
        my $request_url = $self->_build_request_url( $searchtype, @args );
471
1
2
        my $response = $self->_request($request_url);
472
473        # Return empty array if request failed
474
1
2
        return [] unless defined $response;
475
476        # Verify HTTP status code
477
1
1
        my $status_code = $response->code();
478
1
3
        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
5
        my $mime_type = $response->headers->content_type || '';
486
487        # Extract just the base MIME type without parameters (e.g., charset)
488
1
7
        my $base_mime_type = $mime_type;
489
1
2
        $base_mime_type =~ s/;.*$//;    # Remove everything after semicolon
490
1
2
        $base_mime_type =~ s/^\s+|\s+$//g;      # Trim whitespace
491
492
1
4
        my $body = $response->can('body') ? $response->body() : $response->content;
493
494        # Check for XML response
495
1
8
        if($base_mime_type eq 'text/xml' || $base_mime_type eq 'application/xml') {
496
1
2
                return $self->_parse_xml_result( $body, $searchtype eq 'get' );
497        }
498
499        # Check for JSON response
500
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
                if( $body =~ m/\A\{/ ) {
504
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
        if($base_mime_type eq 'text/plain') {
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
524sub geocode {
525
0
0
        my( $self, $q ) = @_;
526
0
0
        $self->search( 'q' => $q );
527}
528
529sub AUTOLOAD {
530
1
4
        my $self = shift;
531        # my $type = ref($self) || croak "$self is not an object";
532
1
2
        ref($self) || croak "$self is not an object";
533
1
0
        my $name = our $AUTOLOAD;
534
1
3
        $name =~ s/.*://;
535
536
1
1
        unless (exists $self->{_functions}->{$name}) {
537
0
0
                croak "No such method '$AUTOLOAD'";
538                }
539
540
1
2
        return($self->_do_search($name, @_));
541}
542
543
8
1855
sub DESTROY { 1 }
544
5451;
546