package Weather::OpenWeatherMap::Cache; $Weather::OpenWeatherMap::Cache::VERSION = '0.005004'; use Carp; use strictures 2; use Scalar::Util 'blessed'; use Storable (); use Time::HiRes (); use Digest::SHA 'sha1_hex'; use List::Objects::WithUtils; use Path::Tiny; use Try::Tiny; use Types::Standard -all; use List::Objects::Types -all; use Types::Path::Tiny -all; use Moo; has dir => ( lazy => 1, is => 'ro', isa => AbsDir, coerce => 1, builder => sub { Path::Tiny->tempdir(CLEANUP => 1) }, ); has expiry => ( is => 'ro', isa => StrictNum, builder => sub { 1200 }, ); ## FIXME max_entries ? or a size limit ? sub serialize { my ($self, $obj) = @_; Storable::freeze( [ Time::HiRes::time, $obj ] ) } sub deserialize { my ($self, $data) = @_; Storable::thaw($data) } sub make_path { my ($self, $obj) = @_; $obj = $obj->request if blessed($obj) and $obj->isa('Weather::OpenWeatherMap::Result'); confess "Expected a Weather::OpenWeatherMap::Request but got $obj" unless blessed($obj) and $obj->isa('Weather::OpenWeatherMap::Request'); my $fname = 'W'; # also see cache_paths; new cache prefixes need added there also TYPE: { if ($obj->isa('Weather::OpenWeatherMap::Request::Current')) { $fname .= 'C'; last TYPE } if ($obj->isa('Weather::OpenWeatherMap::Request::Forecast')) { $fname .= $obj->hourly ? 'H' : 'F'; last TYPE } if ($obj->isa('Weather::OpenWeatherMap::Request::Find')) { $fname .= 'S'; last TYPE } confess "Fell through; no clue what to do with $obj" } my $location = lc $obj->location; my $digest = $^O eq 'Win32' ? substr sha1_hex($location), 0, 25 : sha1_hex($location); # If you happen to alter the extension, check ->cache_paths() too: $fname .= $digest . '.wx'; path( join '/', $self->dir->absolute, $fname ) } sub cache { my ($self, @results) = @_; my $count = 0; for my $result (@results) { confess "Expected a Weather::OpenWeatherMap::Result but got $result" unless blessed($result) and $result->isa('Weather::OpenWeatherMap::Result'); my $request = $result->request; my $path = $self->make_path($request); my $frozen = $self->serialize($result); $path->spew_raw($frozen); ++$count; } $count } sub is_cached { my ($self, $obj) = @_; my $path = $self->make_path($obj); return unless $path->exists; return if $self->expire($obj); $path } sub retrieve { my ($self, $request) = @_; # make_path (via is_cached) will handle Result objs transparently also, but # we need a Request so we can reattach it to returned Result later: $request = $request->request if $request->isa('Weather::OpenWeatherMap::Result'); my $path = $self->is_cached($request); return unless $path; my $data = $path->slurp_raw; my $ref = try { $self->deserialize($data) } catch { warn "deserialize died on retrieve: $_\n"; warn "Attempting to remove possibly corrupt cachefile: $path"; $path->remove; undef }; return unless $ref; my ($ts, $result) = @$ref; unless (defined $ts && defined $result) { warn "cachefile incomplete, removing: $path"; $path->remove; return } # cached Request obj attached to Result may be stale # (e.g. tag may be different for new Request/Result pair) # FIXME tests for this are lacking $result->set_request($request); hash( cached_at => $ts, object => $result )->inflate } sub expire { my ($self, $obj) = @_; return $self->expire_all unless defined $obj; my $path = is_Path($obj) ? $obj : $self->make_path($obj); return unless $path->exists; my $data = $path->slurp_raw; my $ref = try { $self->deserialize($data) } catch { warn "deserialize died on expiry check: $_\n"; warn "Attempting to remove possibly corrupt cachefile: $path"; $path->remove; undef }; return unless $ref; my ($ts) = @$ref; unless ($ts) { warn "expiring possibly corrupt cachefile: $path"; return $path->remove } return $path->remove if Time::HiRes::time - $ts > $self->expiry; () } sub cache_paths { my ($self) = @_; $self->dir->children( qr/^W(?:[CHFS]).+\.wx/ ) } sub expire_all { my ($self) = @_; my @expired; POSSIBLE: for my $maybe ($self->cache_paths) { push @expired, "$maybe" if $self->expire($maybe) } @expired } sub clear { my ($self) = @_; my @removed; POSSIBLE: for my $maybe ($self->cache_paths) { try { my $data = $maybe->slurp_raw; my $ref = $self->deserialize($data); my ($ts, $result) = @$ref; die unless is_StrictNum($ts) and $result->isa('Weather::OpenWeatherMap::Result') } or next POSSIBLE; push @removed, "$maybe"; # Looks like ours; remove() rather than unlink() # (we don't care if it exists or not, at this point) $maybe->remove } @removed } 1; =pod =head1 NAME Weather::OpenWeatherMap::Cache - Cache manager for OpenWeatherMap results =head1 SYNOPSIS # Usually used via Weather::OpenWeatherMap =head1 DESCRIPTION A simple cache manager for L results. =head2 ATTRIBUTES =head3 dir The directory cache files are saved in. Defaults to using a temporary directory that is cleaned up during object destruction (via L / L). If you specify a directory, no automated cleanup is done other than normal object expiry checks during calls to L. =head3 expiry The duration (in seconds) cache files are considered valid; defaults to C<1200>. =head2 METHODS =head3 High-level methods =head4 cache Takes a list of L objects and caches to L. Returns the number of items cached. =head4 retrieve Takes a L and attempts to retrieve a (non-expired) cached L. Returns false if no item was found. If successful, the return value is a simple struct-like object with two attributes, B (the C that the cached item was saved) and B (the relevant L object): my $result; if (my $cached = $cache->retrieve($request)) { $result = $cached->object } =head3 Low-level methods Subclasses can override the following methods to alter cache behavior. =head4 cache_paths Returns a list of L objects representing (what appear to be) L cache files. =head4 clear Walk our L, removing any items that appear to belong to the cache. Returns the list of removed paths (as strings). =head4 deserialize Takes a scalar containing serialized cache data and returns a Perl object or data structure. Uses L by default. =head4 expire Given a L or L, removes relevant stale cache data. If passed no arguments, calls L. Called by L before object retrieval. Returns true if a cached object was expired. =head4 expire_all Expires any stale cache files found in L. =head4 is_cached Takes a L or L and returns boolean true if the object is cached. =head4 make_path Takes a L or L and returns an appropriate L object representing the path that would be used to cache or retrieve the object. =head4 serialize Takes a Perl object or data structure and returns serialized cache data suitable for writing to disk. Uses L by default. =head1 AUTHOR Jon Portnoy =cut