File Coverage

blib/lib/Mojolicious/Plugin/AssetPack/Store.pm
Criterion Covered Total %
statement 171 187 91.4
branch 52 86 60.4
condition 12 23 52.1
subroutine 25 25 100.0
pod 6 6 100.0
total 266 327 81.3


line stmt bran cond sub pod time code
1             package Mojolicious::Plugin::AssetPack::Store;
2 15     15   102 use Mojo::Base 'Mojolicious::Static';
  15         36  
  15         121  
3              
4 15     15   3982 use Mojo::File 'path';
  15         53  
  15         977  
5 15     15   115 use Mojo::Loader 'data_section';
  15         32  
  15         1022  
6 15     15   112 use Mojo::Template;
  15         96  
  15         233  
7 15     15   709 use Mojo::URL;
  15         70  
  15         188  
8 15     15   511 use Mojolicious::Types;
  15         30  
  15         147  
9 15     15   527 use Mojolicious::Plugin::AssetPack::Asset;
  15         32  
  15         151  
10 15     15   643 use Mojolicious::Plugin::AssetPack::Util qw(diag checksum has_ro DEBUG);
  15         31  
  15         1333  
11 15     15   101 use Time::HiRes qw(sleep);
  15         44  
  15         165  
12              
13 15     15   1263 use constant CACHE_DIR => 'cache';
  15         38  
  15         1600  
14              
15             # MOJO_ASSETPACK_DB_FILE is used in tests
16 15   100 15   100 use constant DB_FILE => $ENV{MOJO_ASSETPACK_DB_FILE} || 'assetpack.db';
  15         42  
  15         84258  
17             our %DB_KEYS = map { $_ => 1 } qw(checksum format minified rel);
18             our %FALLBACK_TEMPLATES = %{data_section(__PACKAGE__)};
19              
20             for my $name (keys %FALLBACK_TEMPLATES) {
21             my $text = delete $FALLBACK_TEMPLATES{$name};
22             $name =~ m!(\w+)\.ep$!;
23             $FALLBACK_TEMPLATES{$1} = Mojo::Template->new->parse($text)->prepend('my ($c, $assets) = @_;');
24             }
25              
26             has asset_class => 'Mojolicious::Plugin::AssetPack::Asset';
27             has default_headers => sub { +{"Cache-Control" => "max-age=31536000"} };
28             has fallback_headers => sub { +{"Cache-Control" => "max-age=60"} };
29             has fallback_templates => sub { +{%FALLBACK_TEMPLATES} };
30             has retry_delay => 3;
31             has retries => 0;
32              
33             has _types => sub {
34             my $t = Mojolicious::Types->new;
35             $t->type(eot => 'application/vnd.ms-fontobject');
36             $t->type(otf => 'application/font-otf');
37             $t->type(ttf => 'application/font-ttf');
38             $t->type(woff2 => 'application/font-woff2');
39             delete $t->mapping->{$_} for qw(atom bin htm html txt xml zip);
40             $t;
41             };
42              
43             has_ro 'ua';
44              
45             has_ro _db => sub {
46             my $self = shift;
47             my ($db, $key, $url) = ({});
48             for my $path (reverse map { path($_, DB_FILE) } @{$self->paths}) {
49             open my $DB, '<', $path or next;
50             while (my $line = <$DB>) {
51             ($key, $url) = ($1, $2) if $line =~ /^\[([\w-]+):(.+)\]$/;
52             $db->{$url}{$key}{$1} = $2 if $key and $line =~ /^(\w+)=(.*)/ and $DB_KEYS{$1};
53             }
54             }
55             return $db;
56             };
57              
58             sub asset {
59 39     39 1 434 my ($self, $urls, $paths) = @_;
60 39         80 my $asset;
61              
62 39 50       201 for my $url (ref $urls eq 'ARRAY' ? @$urls : ($urls)) {
63 39 100       281 return $self->_asset_from_helper(Mojo::URL->new($url)) if $url =~ m!^helper://!;
64 36 50       73 for my $path (@{$paths || $self->paths}) {
  36         313  
65 36 50       413 next unless $path =~ m!^https?://!;
66 0         0 my $abs = Mojo::URL->new($path);
67 0         0 $abs->path->merge($url);
68 0 0       0 return $asset if $asset = $self->_already_downloaded($abs);
69             }
70             }
71              
72 36 50       389 for my $url (ref $urls eq 'ARRAY' ? @$urls : ($urls)) {
73 36 100 66     177 return $asset if $url =~ m!^https?://! and $asset = $self->_download(Mojo::URL->new($url));
74              
75 35 50       66 for my $path (@{$paths || $self->paths}) {
  35         150  
76 35 50       280 if ($path =~ m!^https?://!) {
77 0         0 my $abs = Mojo::URL->new($path);
78 0         0 $abs->path->merge($url);
79 0 0       0 return $asset if $asset = $self->_download($abs);
80             }
81             else {
82 35         339 local $self->{paths} = [$path];
83 35 100       255 next unless $asset = $self->file($url);
84 33         7566 return $self->asset_class->new(url => $url, content => $asset);
85             }
86             }
87             }
88              
89 2         445 return undef;
90             }
91              
92             sub load {
93 6     6 1 16 my ($self, $attrs) = @_;
94 6 100       22 my $db_attr = $self->_db_get($attrs) or return undef;
95 4         17 my @rel = $self->_cache_path($attrs);
96 4         22 my $asset = $self->asset(join '/', @rel);
97              
98 4 50       85 return undef unless $asset;
99 4 100       29 return undef unless $db_attr->{checksum} eq $attrs->{checksum};
100 3         7 diag 'Load "%s" = 1', $asset->path || $asset->url if DEBUG;
101 3         34 return $asset;
102             }
103              
104             sub persist {
105 19     19 1 165 my $self = shift;
106 19         146 my $db = $self->_db;
107 19         89 my $path = path($self->paths->[0], DB_FILE);
108 19         757 my @db_keys = sort keys %DB_KEYS;
109 19         55 my $DB;
110              
111 19 100       153 unless (open $DB, '>', $path) {
112 2         107 diag 'Save "%s" = 0 (%s)', $path, $! if DEBUG;
113 2         17 return $self;
114             }
115              
116 17         3763 diag 'Save "%s" = 1', $path if DEBUG;
117 17         156 for my $url (sort keys %$db) {
118 6         11 for my $key (sort keys %{$db->{$url}}) {
  6         26  
119 6 50       37 Carp::confess("Invalid key '$key'. Need to be [a-z-].") unless $key =~ /^[\w-]+$/;
120 6         60 printf $DB "[%s:%s]\n", $key, $url;
121 6         16 for my $attr (@db_keys) {
122 24 100       74 next unless defined $db->{$url}{$key}{$attr};
123 18         59 printf $DB "%s=%s\n", $attr, $db->{$url}{$key}{$attr};
124             }
125             }
126             }
127              
128 17         808 return $self;
129             }
130              
131             sub save {
132 3     3 1 11 my ($self, $ref, $attrs) = @_;
133 3         13 my $path = path($self->paths->[0], $self->_cache_path($attrs));
134 3         166 my $dir = $path->dirname;
135              
136             # Do not care if this fail. Can fallback to temp files.
137 3 50 33     232 mkdir $dir if !-d $dir and -w $dir->dirname;
138 3         109 diag 'Save "%s" = %s', $path, -d $dir ? 1 : 0 if DEBUG;
139              
140 3 50       13 return $self->asset_class->new(%$attrs, content => $$ref) unless -w $dir;
141              
142 3         78 $path->spew($$ref);
143 3         1075 $self->_db_set(%$attrs);
144 3         15 return $self->asset_class->new(%$attrs, path => $path);
145             }
146              
147             sub serve_asset {
148 25     25 1 285 my ($self, $c, $asset) = @_;
149 25         202 my $dh = $self->default_headers;
150 25         184 my $h = $c->res->headers;
151              
152 25         775 $h->header($_ => $dh->{$_}) for keys %$dh;
153 25   100     943 $h->content_type($self->_types->type($asset->format) || 'application/octet-stream');
154              
155 25 50       899 if (my $renderer = $asset->renderer) {
156 0         0 $renderer->($asset, $c);
157             }
158             else {
159 25 50       496 $self->SUPER::serve_asset($c, $asset->can('asset') ? $asset->asset : $asset);
160             }
161              
162 25         10083 return $self;
163             }
164              
165             sub serve_fallback_for_assets {
166 2     2 1 24 my ($self, $c, $topic, $assets) = @_;
167 2         13 my $fh = $self->fallback_headers;
168 2 50       31 my $format = $topic =~ m!\.(\w+)$! ? $1 : 'css';
169 2         11 my $h = $c->res->headers;
170              
171 2         58 $h->header($_ => $fh->{$_}) for keys %$fh;
172 2   50     65 $h->content_type($self->_types->type($format) || 'application/octet-stream');
173              
174 2 100       62 if (my $template = $self->fallback_templates->{$format}) {
    50          
175 1         9 $c->render(data => $template->process($c, $assets));
176             }
177             elsif (@$assets == 1) {
178 1         17 my $url = $assets->[0]->url_for($c);
179 1         608 $url->path->[-1] = $topic;
180 1         41 $c->redirect_to($url);
181             }
182             else {
183 0         0 $c->render(text => "// Invalid checksum for topic '$topic'\n", status => 404);
184             }
185              
186 2         1820 return $self;
187             }
188              
189             sub _already_downloaded {
190 1     1   11 my ($self, $url) = @_;
191 1         5 my $asset = $self->asset_class->new(url => "$url");
192 1         711 my @dirname = $self->_url2path($url, '');
193 1         3 my $basename = pop @dirname;
194              
195 1         4 for my $path (map { path $_, @dirname } @{$self->paths}) {
  1         13  
  1         7  
196              
197             # URL with extension
198 1         48 my $file = $path->child($basename);
199 1 50 33     25 return $asset->format($1)->path($file) if -e $file and $file =~ m!\.(\w+)$!;
200              
201             # URL without extension - https://fonts.googleapis.com/css?family=Roboto
202 1         163 for my $file ($path->list->each) {
203 0 0       0 next unless $file->basename =~ /^$basename(\w+)$/;
204 0         0 return $asset->format($1)->path($file);
205             }
206             }
207              
208 1         65 return undef;
209             }
210              
211             sub _asset_from_helper {
212 3     3   530 my ($self, $url) = @_;
213 3         12 my $app = $self->ua->server->app;
214 3         51 my $args = $url->query->to_hash;
215 3         374 my $helper = $app->renderer->helpers->{$url->host};
216 3         51 my $output = $app->build_controller->$helper($url->path->[0], $args);
217              
218 3 50       621 die "[AssetPack] Unknown helper @{[$url->host]}" unless $helper;
  0         0  
219 3 100       14 my $asset = $self->asset_class->new(url => $url, ref $output ? %$output : (content => $output));
220              
221 3 100       63 $asset->format($args->{format}) if $args->{format};
222 3         32 $asset;
223             }
224              
225             sub _cache_path {
226 7     7   32 my ($self, $attrs) = @_;
227             return (
228             CACHE_DIR, sprintf '%s-%s.%s%s',
229             $attrs->{name},
230             checksum($attrs->{url}),
231             $attrs->{minified} ? 'min.' : '',
232             $attrs->{format}
233 7 50       36 );
234             }
235              
236             sub _db_get {
237 6     6   15 my ($self, $attrs) = @_;
238 6         19 my $db = $self->_db;
239 6 100       45 return undef unless my $data = $db->{$attrs->{url}};
240 4 50       19 return undef unless $data = $data->{$attrs->{key}};
241 4         49 return {%$attrs, %$data};
242             }
243              
244             sub _db_set {
245 3 50   3   16 return if $ENV{MOJO_ASSETPACK_LAZY};
246 3         25 my ($self, %attrs) = @_;
247 3         9 my ($key, $url) = @attrs{qw(key url)};
248 3         26 $self->_db->{$url}{$key} = {%attrs};
249             }
250              
251             sub _download {
252 1     1   147 my ($self, $url) = @_;
253 1         7 my %attrs = (url => $url->clone);
254 1         61 my ($asset, $path);
255              
256 1 50       6 if ($attrs{url}->host eq 'local') {
257 0         0 my $base = $self->ua->server->url;
258 0         0 $url = $url->clone->scheme($base->scheme)->host_port($base->host_port);
259             }
260              
261 1 50 33     10 return $asset if $attrs{url}->host ne 'local' and $asset = $self->_already_downloaded($url);
262              
263 1         3 my $tx;
264 1         5 my $retries = $self->retries;
265 1         7 while (1) {
266 4         55 $tx = $self->ua->get($url);
267 4 100       54957 last unless my $err = $tx->error;
268              
269 3 50       74 if ($retries-- > 0) {
270 3         19 sleep $self->retry_delay;
271 3         302488 next;
272             }
273              
274 0         0 $self->_log->warn("[AssetPack] Unable to download $url: $err->{message}");
275 0         0 return undef;
276             }
277              
278 1         30 my $h = $tx->res->headers;
279 1   50     18 my $ct = $h->content_type || '';
280 1 50       21 if ($ct ne 'text/plain') {
281 1         12 $ct =~ s!;.*$!!;
282 1         8 $attrs{format} = $self->_types->detect($ct)->[0];
283             }
284              
285 1 50 33     359 $attrs{format} ||= $tx->req->url->path->[-1] =~ /\.(\w+)$/ ? $1 : 'bin';
286              
287 1 50       80 if ($attrs{url}->host ne 'local') {
288 1         14 $path = path($self->paths->[0], $self->_url2path($attrs{url}, $attrs{format}));
289 1         62 $self->_log->info(qq(Caching "$url" to "$path".));
290 1 50       486 $path->dirname->make_path unless -d $path->dirname;
291 1         973 $path->spew($tx->res->body);
292             }
293              
294 1         850 $attrs{url} = "$attrs{url}";
295 1 50       363 return $self->asset_class->new(%attrs, path => $path) if $path;
296 0         0 return $self->asset_class->new(%attrs)->content($tx->res->body);
297             }
298              
299 1     1   9 sub _log { shift->ua->server->app->log }
300              
301             sub _url2path {
302 2     2   16 my ($self, $url, $format) = @_;
303 2         11 my $query = $url->query->to_string;
304 2         104 my @path;
305              
306 2         9 push @path, $url->host;
307 2         14 push @path, @{$url->path};
  2         7  
308              
309 2         333 $query =~ s!\W!_!g;
310 2 50       10 $path[-1] .= "_$query.$format" if $query;
311              
312 2         20 return CACHE_DIR, @path;
313             }
314              
315             1;
316              
317             =encoding utf8
318              
319             =head1 NAME
320              
321             Mojolicious::Plugin::AssetPack::Store - Storage for assets
322              
323             =head1 SYNOPSIS
324              
325             use Mojolicious::Lite;
326              
327             # Load plugin and pipes in the right order
328             plugin AssetPack => {pipes => \@pipes};
329              
330             # Change where assets can be found
331             app->asset->store->paths([
332             app->home->rel_file("some/directory"),
333             "/some/other/directory",
334             ]);
335              
336             # Change where assets are stored
337             app->asset->store->paths->[0] = app->home->rel_file("some/directory");
338              
339             # Define asset
340             app->asset->process($moniker => @assets);
341              
342             # Retrieve a Mojolicious::Plugin::AssetPack::Asset object
343             my $asset = app->asset->store->asset("some/file.js");
344              
345             =head1 DESCRIPTION
346              
347             L is an object to manage cached
348             assets on disk.
349              
350             The idea is that a L object can store
351             an asset after it is processed. This will speed up development, since only
352             changed assets will be processed and it will also allow processing tools to
353             be optional in production environment.
354              
355             This module will document meta data about each asset which is saved to disk, so
356             it can be looked up later as a unique item using L.
357              
358             =head1 ATTRIBUTES
359              
360             L inherits all attributes from
361             L implements the following new ones.
362              
363             =head2 asset_class
364              
365             $str = $self->asset_class;
366             $self = $self->asset_class("Mojolicious::Plugin::AssetPack::Asset");
367              
368             Holds the classname of which new assets will be constructed from.
369              
370             =head2 default_headers
371              
372             $hash_ref = $self->default_headers;
373             $self = $self->default_headers({"Cache-Control" => "max-age=31536000"});
374              
375             Used to set headers used by L.
376              
377             =head2 fallback_headers
378              
379             $hash_ref = $self->fallback_headers;
380             $self = $self->fallback_headers({"Cache-Control" => "max-age=300"});
381              
382             Used to set headers used by L.
383              
384             This is currently an EXPERIMENTAL feature.
385              
386             =head2 fallback_templates
387              
388             $hash_ref = $self->fallback_templates;
389             $self = $self->fallback_templates->{"css"} = Mojo::Template->new;
390              
391             Used to set up templates used by L.
392              
393             This is currently an EXPERIMENTAL feature.
394              
395             =head2 paths
396              
397             $paths = $self->paths;
398             $self = $self->paths([$app->home->rel_file("assets")]);
399              
400             See L for details.
401              
402             =head2 retry_delay
403              
404             my $delay = $self->retry_delay;
405             $self = $self->retry_delay(0.5);
406              
407             Delay in seconds between download attempts for assets that need to be fetched, defaults to C<3>.
408              
409             =head2 retries
410              
411             my $retries = $self->retries;
412             $self = $self->retries(5);
413              
414             Number of times asset downloads will be attempted for assets that need to be fetched, defaults to C<0>.
415              
416             =head2 ua
417              
418             $ua = $self->ua;
419              
420             See L.
421              
422             =head1 METHODS
423              
424             L inherits all attributes from
425             L implements the following new ones.
426              
427             =head2 asset
428              
429             $asset = $self->asset($url, $paths);
430              
431             Returns a L object or undef unless
432             C<$url> can be found in C<$paths>. C<$paths> default to
433             L. C<$paths> and C<$url> can be...
434              
435             =over 2
436              
437             =item * helper://some.mojo.helper/some_identifier?format=css
438              
439             Will call a helper registered under the name C, with the
440             query parameters as arguments. Example:
441              
442             $output = $c->some->mojo->helper(some_identifier => {format => "css"});
443              
444             C<$output> can be a scalar containing the asset content or a hash-ref with
445             arguments passed on to L. Note that
446             C need to be present in the URL or the returning hash-ref for this
447             to work.
448              
449             This feature is currently EXPERIMENTAL. Let me know if you use it/find it
450             interesting.
451              
452             =item * http://example.com/foo/bar
453              
454             An absolute URL will be downloaded from web, unless the host is "local":
455             "local" is a special host which will run the request through the current
456             L application.
457              
458             =item * foo/bar
459              
460             An relative URL will be looked up using L.
461              
462             =back
463              
464             Note that assets from web will be cached locally, which means that you need to
465             delete the files on disk to download a new version.
466              
467             =head2 load
468              
469             $bool = $self->load($asset, \%attr);
470              
471             Used to load an existing asset from disk. C<%attr> will override the
472             way an asset is looked up. The example below will ignore
473             L and rather use
474             the value from C<%attr>:
475              
476             $bool = $self->load($asset, {minified => $bool});
477              
478             =head2 persist
479              
480             $self = $self->persist;
481              
482             Used to save the internal state of the store to disk.
483              
484             This method is EXPERIMENTAL, and may change without warning.
485              
486             =head2 save
487              
488             $bool = $self->save($asset, \%attr);
489              
490             Used to save an asset to disk. C<%attr> are usually the same as
491             L and used to document metadata
492             about the C<$asset> so it can be looked up using L.
493              
494             =head2 serve_asset
495              
496             $self = $self->serve_asset($c, $asset);
497              
498             Override L with the functionality to set
499             response headers first, from L.
500              
501             Will call L if available, after
502             setting Content-Type header and other L.
503              
504             =head2 serve_fallback_for_assets
505              
506             $self = $self->serve_fallback_for_assets($c, $topic, $assets);
507              
508             Used to serve a fallback response for given C<$topic> and a
509             L of C objects.
510              
511             Will set the headers in L and then either render either a
512             template matching the extension from C<$topic> from L, a
513             302 redirect to the actual asset, or a 404 Not Found.
514              
515             This is currently an EXPERIMENTAL feature.
516              
517             =head1 SEE ALSO
518              
519             L.
520              
521             =cut
522              
523             __DATA__