File Coverage

File:blib/lib/TimeZone/TimeZoneDB.pm
Coverage:95.5%

linestmtbrancondsubtimecode
1package TimeZone::TimeZoneDB;
2
3
10
10
10
1049853
6
130
use strict;
4
10
10
10
12
6
201
use warnings;
5
10
10
10
1782
56373
20
use autodie qw(:all);
6
7
10
10
10
72825
11
208
use Carp;
8
10
10
10
2053
254290
126
use CHI;
9
10
10
10
24
7
214
use JSON::MaybeXS;
10
10
10
10
2961
116325
176
use LWP::UserAgent;
11
10
10
10
2829
389706
182
use Object::Configure;
12
10
10
10
33
73
158
use Params::Get 0.13;
13
10
10
10
14
57
111
use Params::Validate::Strict 0.10;
14
10
10
10
14
7
142
use Readonly;
15
10
10
10
11
8
83
use Return::Set;
16
10
10
10
13
5
81
use Scalar::Util;
17
10
10
10
19
9
24
use Time::HiRes;
18
10
10
10
323
10
6062
use URI;
19
20 - 28
=head1 NAME

TimeZone::TimeZoneDB - Interface to L<https://timezonedb.com> for looking up Timezone data

=head1 VERSION

Version 0.05

=cut
29
30our $VERSION = '0.05';
31
32# ---------------------------------------------------------------------------
33# Compile-time constants for the timezonedb.com REST API.
34# These never vary and are inlined by the compiler.
35# ---------------------------------------------------------------------------
36Readonly::Scalar my $API_FORMAT    => 'json';
37Readonly::Scalar my $API_BY        => 'position';    # query by geographic position
38
39# printf-style format for the cache key; 6 dp normalises 0.1 and 0.1000000
40Readonly::Scalar my $CACHE_KEY_FMT => 'tz:%.6f:%.6f';
41
42# Valid coordinate ranges as defined by the WGS-84 standard
43Readonly::Scalar my $LAT_MIN => -90;
44Readonly::Scalar my $LAT_MAX =>  90;
45Readonly::Scalar my $LNG_MIN => -180;
46Readonly::Scalar my $LNG_MAX =>  180;
47
48# ---------------------------------------------------------------------------
49# Runtime defaults.  Every key may be overridden via Object::Configure,
50# which reads from a per-class configuration file or environment variables.
51# ---------------------------------------------------------------------------
52my %config = (
53        host          => 'api.timezonedb.com',       # remote API hostname
54        api_version   => 'v2.1',             # path component for the API version
55        api_endpoint  => 'get-time-zone',    # path component for the lookup method
56        cache_expires => '1 day',            # CHI expiry string for cached responses
57        min_interval  => 0,                  # minimum seconds between outbound requests
58);
59
60 - 186
=head1 SYNOPSIS

    use TimeZone::TimeZoneDB;

    my $tzdb = TimeZone::TimeZoneDB->new(key => 'XXXXXXXX');
    my $tz = $tzdb->get_time_zone({ latitude => 0.1, longitude => 0.2 });

=head1 DESCRIPTION

The C<TimeZone::TimeZoneDB> Perl module provides an interface to the
L<https://timezonedb.com> API, enabling users to retrieve timezone data
based on geographic coordinates.
It supports configurable HTTP user agents, allowing for proxy settings
and request throttling.
The module includes robust error handling, ensuring proper validation of
input parameters and secure API interactions.
JSON responses are safely parsed with error handling to prevent crashes.
Designed for flexibility, it allows users to override default configurations
while maintaining a lightweight and efficient structure for querying timezone
information.

=over 4

=item * Caching

Identical requests are cached (using L<CHI> or a user-supplied caching object),
reducing the number of HTTP requests to the API and speeding up repeated queries.

A cache key is constructed from the normalised coordinates (6 decimal places)
so that C<0.1> and C<0.1000000> share the same cache entry.

=item * Rate-Limiting

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.

Rate-limiting is implemented using L<Time::HiRes>.
A minimum interval between API calls can be specified via the C<min_interval>
parameter in the constructor.
Before making an API call, the module checks how much time has elapsed since
the last request and, if necessary, sleeps for the remaining time.

=back

=head1 METHODS

=head2 new

    my $tzdb = TimeZone::TimeZoneDB->new(key => 'XXXXX');

    # With a throttled user-agent that respects free-tier rate limits
    use LWP::UserAgent::Throttled;
    my $ua = LWP::UserAgent::Throttled->new();
    $ua->env_proxy(1);
    $tzdb = TimeZone::TimeZoneDB->new(ua => $ua, key => 'XXXXX');

    # Retrieve the timezone for Ramsgate, UK
    my $tz = $tzdb->get_time_zone({ latitude => 51.34, longitude => 1.42 })->{'zoneName'};
    print "Ramsgate timezone: $tz\n";

Creates and returns a new C<TimeZone::TimeZoneDB> instance.
When invoked on an existing object rather than a class name, it returns a
shallow clone of that object with any supplied parameters merged in.
Passing C<ua =E<gt> undef> in a clone call is silently ignored so that the
original user-agent is inherited unchanged.

=head3 ARGUMENTS

=over 4

=item C<key> (required)

API key for timezonedb.com.  Free keys are available at
L<https://timezonedb.com/register>.

=item C<ua> (optional)

An HTTP user-agent object.  Must respond to C<get()>.  Defaults to a plain
L<LWP::UserAgent> with C<gzip,deflate> accepted.

=item C<host> (optional)

Override the API hostname.  Defaults to C<api.timezonedb.com>.

=item C<cache> (optional)

A L<CHI>-compatible caching object.  Defaults to a private in-memory cache
with a one-day expiry.

=item C<min_interval> (optional)

Minimum number of seconds to wait between successive API calls.
Defaults to C<0> (no enforced delay).

=back

=head3 RETURNS

A blessed C<TimeZone::TimeZoneDB> reference.
Croaks if C<key> is absent.

=head3 SIDE EFFECTS

None.

=head3 NOTES

An optional C<logger> key may be passed; if present it must be an object
implementing C<warn()> and C<error()> (e.g. L<Log::Log4perl>).

=head3 API SPECIFICATION

=head4 INPUT

  {
    'key'          => { type => 'string' },
    'ua'           => { type => 'object', can => 'get',    optional => 1 },
    'host'         => { type => 'string',                  optional => 1 },
    'cache'        => { type => 'object',                  optional => 1 },
    'min_interval' => { type => 'number', min => 0,        optional => 1 },
  }

=head4 OUTPUT

  { type => 'object' }   # a blessed TimeZone::TimeZoneDB reference

=cut
187
188sub new
189{
190
243
1045511
        my $class = shift;
191        # Normalise both positional and named calling conventions
192
243
376
        my $params = Params::Get::get_params(undef, \@_) || {};
193
194        # Support function-style call: TimeZone::TimeZoneDB::new() without ->
195
243
3215
        if(!defined($class)) {
196
2
1
                $class = __PACKAGE__;
197        } elsif(Scalar::Util::blessed($class)) {
198                # If $class is an object, clone it with new arguments
199                # Clone path: merge new params over the existing object's fields.
200
22
25
                if(exists($params->{ua})) {
201
11
26
                        if(!defined($params->{ua})) {
202                                # ua=>undef means "keep the original" -- silently drop it
203
5
4
                                delete $params->{ua};
204                        } elsif(!Scalar::Util::blessed($params->{ua}) || !$params->{ua}->can('get')) {
205                                # A defined ua must be a proper object with a get() method
206
4
19
                                Carp::croak("'ua' argument must be an object with a get() method");
207                        }
208                }
209
18
18
18
10
22
50
                return bless { %{$class}, %{$params} }, ref($class);
210        }
211
212        # Merge any file- or environment-based configuration into $params
213
221
249
        $params = Object::Configure::configure($class, $params);
214
215        # The API key is the only mandatory argument
216
221
49351
        my $key = $params->{'key'} or Carp::croak("'key' argument is required");
217
218        # Build a default user-agent if the caller did not supply one
219
214
136
        my $ua = $params->{ua};
220
214
170
        if(!defined($ua)) {
221
88
193
                $ua = LWP::UserAgent->new(agent => __PACKAGE__ . "/$VERSION");
222
88
18696
                $ua->default_header(accept_encoding => 'gzip,deflate');
223        }
224
225        # Prefer an explicit host override, then the package-level default
226
214
2137
        my $host = $params->{host} || $config{host};
227
228        # Fall back to a private in-memory cache if none was supplied
229        my $cache = $params->{cache} || CHI->new(
230                driver     => 'Memory',
231                global     => 0,
232                expires_in => $config{cache_expires},
233
214
392
        );
234
235        # Use // so that an explicit 0 (disabled) is not replaced by the default
236
214
364087
        my $min_interval = $params->{min_interval} // $config{min_interval};
237
238        return bless {
239                key          => $key,
240                min_interval => $min_interval,
241                last_request => 0,           # epoch zero: no request has been made yet
242
214
214
145
676
                %{$params},                     # pass any extra keys (e.g. logger) through
243                cache        => $cache,              # computed values override %{$params} copies
244                host         => $host,
245                ua           => $ua,
246        }, $class;
247}
248
249 - 314
=head2 get_time_zone

    my $result = $tzdb->get_time_zone({ latitude => 51.34, longitude => 1.42 });
    print $result->{'zoneName'}, "\n";

    # Also accepts a Geo::Location::Point-compatible object
    use Geo::Location::Point;
    my $ramsgate = Geo::Location::Point->new({ latitude => 51.34, longitude => 1.42 });
    my $tz = $tzdb->get_time_zone($ramsgate)->{'zoneName'};

Queries the timezonedb.com API for the IANA timezone name and associated
metadata at the supplied geographic coordinates.
Identical queries are served from cache without making a network request.

=head3 ARGUMENTS

=over 4

=item C<latitude> (required)

Decimal degrees, range C<-90> to C<+90>.

=item C<longitude> (required)

Decimal degrees, range C<-180> to C<+180>.

Alternatively, a single L<Geo::Location::Point>-compatible object (any
object implementing C<latitude()> and C<longitude()> methods) may be passed
instead of a hash or hashref.

=back

=head3 RETURNS

A hashref containing at least C<zoneName> on success.
Returns C<undef> when the API responds with a non-C<OK> status.
Croaks on HTTP errors or invalid arguments.

=head3 SIDE EFFECTS

Updates the internal response cache and the C<last_request> timestamp.

=head3 NOTES

The API key is transmitted as a URL query parameter because the
timezonedb.com API does not support an C<Authorization> header.
The key is redacted from all error and warning messages to prevent
accidental secret leakage into log aggregators or crash reporters.

=head3 API SPECIFICATION

=head4 INPUT

  {
    'latitude'  => { type => 'number', min => -90,  max => 90  },
    'longitude' => { type => 'number', min => -180, max => 180 },
  }

=head4 OUTPUT

  Argument error : croak
  HTTP error     : croak
  Non-OK status  : undef
  Success        : { type => 'hashref', min => 1 }

=cut
315
316sub get_time_zone
317{
318
155
2012767
        my $self = shift;
319
155
73
        my $params;
320
321        # Accept a Geo::Location::Point-compatible object or a plain hash/hashref
322
155
278
        if((@_ == 1) && Scalar::Util::blessed($_[0]) && $_[0]->can('latitude')) {
323
9
9
                my $location = $_[0];
324
9
11
                $params->{latitude}  = $location->latitude();
325
9
50
                $params->{longitude} = $location->longitude();
326        } else {
327
146
170
                $params = Params::Get::get_params(undef, \@_);
328        }
329
330        # Validate coordinate ranges; croaks on out-of-range or missing values
331
154
1884
        $params = Params::Validate::Strict::validate_strict(
332                args   => $params,
333                schema => {
334                        'latitude'  => { type => 'number', min => $LAT_MIN, max => $LAT_MAX },
335                        'longitude' => { type => 'number', min => $LNG_MIN, max => $LNG_MAX },
336                }
337        );
338
339
132
14377
        my $latitude  = $params->{latitude};
340
132
81
        my $longitude = $params->{longitude};
341
342        # Params::Validate::Strict silently skips type/range checks for undef values,
343        # so guard explicitly to avoid sprintf warnings and silent URL corruption
344
132
125
        Carp::croak("Required parameter 'latitude' must be defined")  unless defined($latitude);
345
130
101
        Carp::croak("Required parameter 'longitude' must be defined") unless defined($longitude);
346
347        # Build the full API URL; key must go in the query string (API requirement)
348        my $uri = URI->new(
349                sprintf('https://%s/%s/%s',
350                        $self->{host},
351                        $config{api_version},
352                        $config{api_endpoint}
353                )
354
129
275
        );
355        $uri->query_form(
356                by     => $API_BY,
357                lat    => $latitude,
358                lng    => $longitude,
359                format => $API_FORMAT,
360
129
32413
                key    => $self->{'key'},
361        );
362
129
7925
        my $url = $uri->as_string();
363
364        # Normalise to 6 dp so that 0.1 and 0.1000000 share the same cache slot
365
129
581
        my $cache_key = sprintf($CACHE_KEY_FMT, $latitude, $longitude);
366
129
172
        if(my $cached = $self->{cache}->get($cache_key)) {
367
14
1058
                return $cached;
368        }
369
370        # Sleep if needed to honour the caller's minimum inter-request interval
371
115
3679
        my $now     = time();
372
115
87
        my $elapsed = $now - $self->{last_request};
373
115
145
        if($elapsed < $self->{min_interval}) {
374
5
1000192
                Time::HiRes::sleep($self->{min_interval} - $elapsed);
375        }
376
377        # Perform the HTTP GET; all transport details are the UA's responsibility
378
115
172
        my $res = $self->{ua}->get($url);
379
380        # Stamp the request time before any early return so rate-limiting is correct
381
115
2059
        $self->{last_request} = time();
382
383        # Redact the API key before including the URL in any error message
384
115
141
        if($res->is_error()) {
385
8
44
                (my $safe_url = $url) =~ s/key=[^&]*/key=REDACTED/;
386
8
13
                if(my $logger = $self->{logger}) {
387
0
0
                        $logger->error($safe_url . ' API returned error: ' . $res->status_line());
388                }
389
8
18
                Carp::croak($safe_url . ' API returned error: ' . $res->status_line());
390        }
391
392        # Safely decode the JSON body; a malformed response is a soft failure
393
105
343
        my $rc;
394
105
105
51
598
        eval { $rc = JSON::MaybeXS->new()->utf8()->decode($res->decoded_content()) };
395
105
9908
        if($@) {
396
6
10
                if(my $logger = $self->{logger}) {
397
0
0
                        $logger->warn("Failed to parse JSON response: $@");
398                }
399
6
26
                Carp::carp("Failed to parse JSON response: $@");
400
6
1840
                return;
401        }
402
403        # Cache the decoded response before returning so the next caller is served
404
99
133
        $self->{'cache'}->set($cache_key, $rc);
405
406        # A non-OK API status means the coordinates returned no result
407
99
13361
        if($rc && defined($rc->{'status'}) && ($rc->{'status'} ne 'OK')) {
408
12
25
                if(my $logger = $self->{'logger'}) {
409
5
14
                        (my $safe_url = $url) =~ s/key=[^&]*/key=REDACTED/;
410
5
15
                        $logger->warn(__PACKAGE__ . ": $safe_url returns $rc->{status}");
411                }
412
12
41
                return;
413        }
414
415        # Assert the output contract: a non-empty hashref
416
86
154
        return Return::Set::set_return($rc, { 'type' => 'hashref', 'min' => 1 });
417}
418
419 - 482
=head2 ua

    # Getter: retrieve the current user-agent
    my $ua = $tzdb->ua();
    $ua->env_proxy(1);

    # Setter: swap in a throttled agent (returns the new agent for compatibility)
    use LWP::UserAgent::Throttled;
    my $new_ua = LWP::UserAgent::Throttled->new();
    $new_ua->throttle('timezonedb.com' => 1);
    $tzdb->ua($new_ua);

Gets or sets the HTTP user-agent object used for API requests.
The return value is always the current user-agent (after any update),
consistent with the convention used by L<LWP::UserAgent> and related
packages that expose a C<ua()> accessor.

=head3 ARGUMENTS

=over 4

=item C<ua> (optional)

Replacement user-agent object.  Must implement a C<get($url)> method.
Omit to use this method as a getter.

=back

=head3 RETURNS

The user-agent object stored on the instance -- the supplied value when
called as a setter, the existing value when called as a getter.
Croaks if a defined but invalid object (no C<get()> method) is supplied,
or if C<undef> is explicitly passed.

=head3 SIDE EFFECTS

When used as a setter, all subsequent API calls on this object use the new
user-agent.

=head3 NOTES

Free timezonedb.com accounts are rate-limited to one request per second.
Use L<LWP::UserAgent::Throttled> to enforce this transparently.

The accessor always returns the user-agent rather than C<$self> so that
callers can do C<$tzdb-E<gt>ua()-E<gt>env_proxy(1)> in a single expression
without ambiguity about what was returned.

=head3 API SPECIFICATION

=head4 INPUT

  # Getter (no argument)
  {}

  # Setter
  { 'ua' => { type => 'object', can => 'get' } }

=head4 OUTPUT

  { type => 'object' }   # the stored user-agent (getter or setter)

=cut
483
484sub ua {
485
77
10804
        my $self = shift;
486
487        # Getter path: no arguments, return the stored agent immediately
488
77
107
        return $self->{ua} unless @_;
489
490        # Params::Get::get_params('ua', \@_) mis-routes the named form ua(ua => $ref)
491        # into { ua => [$key, $ref] } because its $array_ref path fires before the
492        # even-count hash path when the value is a reference (Params::Get line 258).
493        # Detect and fix the named-pair form manually before passing to validate_strict.
494
50
30
        my $args;
495
50
97
        if(@_ == 2 && defined($_[0]) && !ref($_[0]) && $_[0] eq 'ua') {
496
5
6
                $args = { ua => $_[1] };     # named: ua(ua => $obj)
497        } else {
498
45
52
                $args = Params::Get::get_params('ua', \@_);     # positional: ua($obj)
499        }
500
501        # Validate that the supplied object implements the interface we depend on
502
49
654
        my $params = Params::Validate::Strict::validate_strict(
503                args   => $args,
504                schema => {
505                        ua => {
506                                type => 'object',
507                                can  => 'get',
508                        }
509                }
510        );
511
512        # Params::Validate::Strict skips the type check for undef 'object' params,
513        # so we must guard explicitly to prevent silent corruption of $self->{ua}
514
31
2160
        if(!defined($params->{ua})) {
515
11
15
                if(my $logger = $self->{'logger'}) {
516
4
8
                        $logger->error('ua() requires a defined value');
517                }
518
11
76
                Carp::croak('ua() requires a defined value');
519        }
520
521        # Store the new agent and return it, consistent with LWP::UserAgent convention
522
20
20
        $self->{ua} = $params->{ua};
523
20
66
        return $self->{ua};
524}
525
526 - 632
=head1 AUTHOR

Nigel Horne, C<< <njh@nigelhorne.com> >>

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

Lots of thanks to the folks at L<https://timezonedb.com>.

=head1 BUGS

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-timezone-timezonedb at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=TimeZone-TimeZoneDB>.
I will be notified, and then you'll automatically be notified of progress
on your bug as I make changes.

=head1 SEE ALSO

=over 4

=item * TimezoneDB API: L<https://timezonedb.com/api>

=item * L<Test Dashboard|https://nigelhorne.github.io/TimeZone-TimeZoneDB/coverage/>

=back

=encoding utf-8

=head2 FORMAL SPECIFICATION

=head3 new

  TimeZoneDB-State ::= [
    key          : STRING ;
    ua           : USERAGENT ;
    host         : STRING ;
    cache        : CACHE ;
    min_interval : â„• ;
    last_request : â„•
  ]

  Init
    key?          : STRING
    ua?           : USERAGENT ∪ {⊥}
    host?         : STRING ∪ {⊥}
    cache?        : CACHE ∪ {⊥}
    min_interval? : â„• ∪ {⊥}
    result!       : TimeZoneDB-State
  â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    key? ≠ "" ∧
    result!.key          = key? ∧
    result!.ua           = (if ua? ≠ ⊥ then ua? else DefaultUA) ∧
    result!.host         = (if host? ≠ ⊥ then host? else config.host) ∧
    result!.cache        = (if cache? ≠ ⊥ then cache? else NewCache) ∧
    result!.min_interval = (if min_interval? ≠ ⊥ then min_interval? else 0) ∧
    result!.last_request = 0

=head3 get_time_zone

  GetTimeZone
    Î” TimeZoneDB-State   (writes cache and last_request)
    lat? : {n : ℝ | -90 ≤ n ≤ 90}
    lng? : {n : ℝ | -180 ≤ n ≤ 180}
    result! : HASHREF ∪ {⊥}
  â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    let k == sprintf(CACHE_KEY_FMT, lat?, lng?)
    âˆ§ cache.has(k) ⇒
          result! = cache.get(k)
        âˆ§ last_request' = last_request
        âˆ§ cache' = cache
    âˆ§ ¬cache.has(k) ⇒
          let r == ua.get(ApiUrl(lat?, lng?, key))
          âˆ§ ¬r.ok ⇒ ⊥
          âˆ§ r.ok ∧ r.json.status = "OK" ⇒
                result! = r.json
              âˆ§ cache' = cache ⊕ {k ↦ r.json}
              âˆ§ last_request' = now
          âˆ§ r.ok ∧ r.json.status ≠ "OK" ⇒
                result! = ⊥
              âˆ§ cache' = cache
              âˆ§ last_request' = now

=head2 ua

  UA
    Delta TimeZoneDB-State
    ua? : USERAGENT ∪ {⊥}   (⊥ = not supplied)
    ua! : USERAGENT
  â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
    (ua? = ⊥ ∧ ua' = ua) ∨
    (ua? ≠ ⊥ ∧ defined(ua?) ∧ ua? can 'get'
             âˆ§ ua' = ua?
             âˆ§ ∀ x : {key, host, cache, min_interval, last_request} • x' = x)
    âˆ§ ua! = ua'

=head1 LICENSE AND COPYRIGHT

Copyright 2023-2026 Nigel Horne.

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut
633
6341;