File Coverage

blib/lib/Dash.pm
Criterion Covered Total %
statement 170 339 50.1
branch 27 116 23.2
condition 9 41 21.9
subroutine 22 34 64.7
pod 2 4 50.0
total 230 534 43.0


line stmt bran cond sub pod time code
1             package Dash;
2              
3 6     6   780355 use Moo;
  6         48041  
  6         32  
4 6     6   10287 use strictures 2;
  6         8395  
  6         205  
5 6     6   1048 use 5.020;
  6         17  
6              
7             our $VERSION = '0.11'; # VERSION
8              
9             # ABSTRACT: Analytical Web Apps in Perl (Port of Plotly's Dash to Perl)
10              
11             # TODO Enable signatures?
12              
13 6     6   3333 use JSON;
  6         54376  
  6         29  
14 6     6   807 use Scalar::Util;
  6         10  
  6         210  
15 6     6   2364 use Browser::Open;
  6         9612  
  6         252  
16 6     6   4117 use Path::Tiny;
  6         58595  
  6         316  
17 6     6   2367 use Dash::Renderer;
  6         22  
  6         175  
18 6     6   2135 use Dash::Config;
  6         22  
  6         231  
19 6     6   2574 use Dash::Exceptions::NoLayoutException;
  6         15  
  6         198  
20 6     6   1856 use Dash::Exceptions::PreventUpdate;
  6         23  
  6         217  
21 6     6   2288 use Dash::Backend::Mojolicious::App;
  6         21  
  6         69  
22 6     6   248 use namespace::clean;
  6         13  
  6         54  
23              
24             # TODO Add ci badges
25              
26             has app_name => ( is => 'ro',
27             default => __PACKAGE__ );
28              
29             has port => ( is => 'ro',
30             default => 8080 );
31              
32             has external_stylesheets => ( is => 'rw',
33             default => sub { [] } );
34              
35             has _layout => ( is => 'rw',
36             default => sub { {} } );
37              
38             has _callbacks => ( is => 'rw',
39             default => sub { {} } );
40              
41             has _rendered_scripts => ( is => 'rw',
42             default => "" );
43              
44             has _rendered_external_stylesheets => ( is => 'rw',
45             default => "" );
46              
47             has backend => ( is => 'rw',
48             default => sub { Dash::Backend::Mojolicious::App->new( dash_app => shift ) } );
49              
50             has config => ( is => 'rw',
51             default => sub { Dash::Config->new() } );
52              
53             sub layout {
54 13     13 1 2321 my $self = shift;
55 13         21 my $layout = shift;
56 13 100       33 if ( defined $layout ) {
57 11         21 my $type = ref $layout;
58 11 100 66     100 if ( $type eq 'CODE' || ( Scalar::Util::blessed($layout) && $layout->isa('Dash::BaseComponent') ) ) {
      66        
59 9         42 $self->_layout($layout);
60             } else {
61 2         15 Dash::Exceptions::NoLayoutException->throw(
62             'Layout must be a dash component or a function that returns a dash component');
63             }
64             } else {
65 2         8 $layout = $self->_layout;
66             }
67 11         40 return $layout;
68             }
69              
70             sub callback {
71 8     8 1 115 my $self = shift;
72 8         31 my %callback = $self->_process_callback_arguments(@_);
73              
74             # TODO check_callback
75             # TODO Callback map
76 8         15 my $output = $callback{Output};
77 8         23 my $callback_id = $self->_create_callback_id($output);
78 8         35 my $callbacks = $self->_callbacks;
79 8         19 $callbacks->{$callback_id} = \%callback;
80 8         43 return $self;
81             }
82              
83             my $no_update;
84             my $internal_no_update = bless( \$no_update, 'Dash::Internal::NoUpdate' );
85              
86             sub no_update {
87 0     0 0 0 return $internal_no_update;
88             }
89              
90             sub _process_callback_arguments {
91 8     8   13 my $self = shift;
92              
93 8         12 my %callback;
94              
95             # 1. all refs: 1 blessed, 1 array, 1 code or 2 array, 1 code
96             # Hash with keys Output, Inputs, callback
97             # 2. Values content: hashref or arrayref[hashref], arrayref[hashref], coderef
98             # 3. Values content: blessed output or arrayref[blessed], arrayref[blessed], coderef
99              
100 8 50       19 if ( scalar @_ < 5 ) { # Unamed arguments, put names
101 0         0 my ( $output_index, $input_index, $state_index, $callback_index );
102              
103 0         0 my $index = 0;
104 0         0 for my $argument (@_) {
105 0         0 my $type = ref $argument;
106 0 0       0 if ( $type eq 'CODE' ) {
    0          
    0          
    0          
    0          
    0          
107 0         0 $callback_index = $index;
108             } elsif ( Scalar::Util::blessed $argument) {
109 0 0       0 if ( $argument->isa('Dash::Dependencies::Output') ) {
110 0         0 $output_index = $index;
111             }
112             } elsif ( $type eq 'ARRAY' ) {
113 0 0       0 if ( scalar @$argument > 0 ) {
114 0         0 my $first_element = $argument->[0];
115 0 0       0 if ( Scalar::Util::blessed $first_element) {
116 0 0       0 if ( $first_element->isa('Dash::Dependencies::Output') ) {
    0          
    0          
117 0         0 $output_index = $index;
118             } elsif ( $first_element->isa('Dash::Dependencies::Input') ) {
119 0         0 $input_index = $index;
120             } elsif ( $first_element->isa('Dash::Dependencies::State') ) {
121 0         0 $state_index = $index;
122             }
123             }
124             } else {
125 0         0 die "Can't use empty arrayrefs as arguments";
126             }
127             } elsif ( $type eq 'SCALAR' ) {
128 0         0 die
129             "Can't mix scalarref arguments with objects when not using named paremeters. Please use named parameters for all arguments or classes for all arguments";
130             } elsif ( $type eq 'HASH' ) {
131 0         0 die
132             "Can't mix hashref arguments with objects when not using named parameters. Please use named parameters for all arguments or classes for all arguments";
133             } elsif ( $type eq '' ) {
134 0         0 die
135             "Can't mix scalar arguments with objects when not using named parameters. Please use named parameters for all arguments or classes for all arguments";
136             }
137 0         0 $index++;
138             }
139 0 0       0 if ( !defined $output_index ) {
140 0         0 die "Can't find callback output";
141             }
142 0 0       0 if ( !defined $input_index ) {
143 0         0 die "Can't find callback inputs";
144             }
145 0 0       0 if ( !defined $callback_index ) {
146 0         0 die "Can't find callback function";
147             }
148              
149 0         0 $callback{Output} = $_[$output_index];
150 0         0 $callback{Inputs} = $_[$input_index];
151 0         0 $callback{callback} = $_[$callback_index];
152 0 0       0 if ( defined $state_index ) {
153 0         0 $callback{State} = $_[$state_index];
154             }
155             } else { # Named arguments
156             # TODO check keys ¿Params::Validate or similar?
157 8         31 %callback = @_;
158             }
159              
160             # Convert Output & input to hashrefs
161 8         37 for my $key ( keys %callback ) {
162 26         33 my $value = $callback{$key};
163              
164 26 100       78 if ( ref $value eq 'ARRAY' ) {
    50          
165 12         14 my @hashes;
166 12         21 for my $dependency (@$value) {
167 14 50       25 if ( Scalar::Util::blessed $dependency) {
168 0         0 my %dependency_hash = %$dependency;
169 0         0 push @hashes, \%dependency_hash;
170             } else {
171 14         24 push @hashes, $dependency;
172             }
173             }
174 12         23 $callback{$key} = \@hashes;
175             } elsif ( Scalar::Util::blessed $value) {
176 0         0 my %dependency_hash = %$value;
177 0         0 $callback{$key} = \%dependency_hash;
178             }
179             }
180              
181 8         37 return %callback;
182             }
183              
184             sub _create_callback_id {
185 8     8   11 my $self = shift;
186 8         12 my $output = shift;
187              
188 8 100       22 if ( ref $output eq 'ARRAY' ) {
189 2         7 return ".." . join( "...", map { $_->{component_id} . "." . $_->{component_property} } @$output ) . "..";
  4         18  
190             }
191              
192 6         18 return $output->{component_id} . "." . $output->{component_property};
193             }
194              
195             sub run_server {
196 0     0 0 0 my $self = shift;
197              
198 0         0 $self->_render_and_cache_scripts();
199 0         0 $self->_render_and_cache_external_stylesheets();
200              
201             # Opening the browser before starting the daemon works because
202             # open_browser returns inmediately
203             # TODO Open browser optional
204 0 0       0 if ( not caller(1) ) {
205 0         0 Browser::Open::open_browser( 'http://127.0.0.1:' . $self->port );
206 0         0 $self->backend->start( 'daemon', '-l', 'http://*:' . $self->port );
207             }
208 0         0 return $self->backend;
209             }
210              
211             sub _dependencies {
212 4     4   24 my $self = shift;
213 4         12 my $dependencies = [];
214 4         7 for my $callback ( values %{ $self->_callbacks } ) {
  4         18  
215 3         11 my $rendered_callback = { clientside_function => JSON::null };
216 3         14 my $states = [];
217 3         4 for my $state ( @{ $callback->{State} } ) {
  3         9  
218             my $rendered_state = { id => $state->{component_id},
219             property => $state->{component_property}
220 1         4 };
221 1         4 push @$states, $rendered_state;
222             }
223 3         5 $rendered_callback->{state} = $states;
224 3         18 my $inputs = [];
225 3         5 for my $input ( @{ $callback->{Inputs} } ) {
  3         5  
226             my $rendered_input = { id => $input->{component_id},
227             property => $input->{component_property}
228 3         9 };
229 3         6 push @$inputs, $rendered_input;
230             }
231 3         4 $rendered_callback->{inputs} = $inputs;
232 3         13 my $output_type = ref $callback->{Output};
233 3 100       11 if ( $output_type eq 'ARRAY' ) {
    50          
234 1         2 $rendered_callback->{'output'} .= '.';
235 1         3 for my $output ( @{ $callback->{'Output'} } ) {
  1         2  
236             $rendered_callback->{'output'} .=
237 2         58 '.' . join( '.', $output->{component_id}, $output->{component_property} ) . '..';
238             }
239             } elsif ( $output_type eq 'HASH' ) {
240             $rendered_callback->{'output'} =
241 2         9 join( '.', $callback->{'Output'}{component_id}, $callback->{'Output'}{component_property} );
242             } else {
243 0         0 die 'Dependecy type for callback not implemented';
244             }
245 3         6 push @$dependencies, $rendered_callback;
246             }
247 4         14 return $dependencies;
248             }
249              
250             sub _update_component {
251 6     6   139 my $self = shift;
252 6         11 my $request = shift;
253              
254 6 100       8 if ( scalar( values %{ $self->_callbacks } ) > 0 ) {
  6         28  
255 5         19 my $callbacks = $self->_search_callback( $request->{'output'} );
256 5 50       31 if ( scalar @$callbacks > 1 ) {
    50          
257 0         0 die 'Not implemented multiple callbacks';
258             } elsif ( scalar @$callbacks == 1 ) {
259 5         10 my $callback = $callbacks->[0];
260 5         13 my @callback_arguments = ();
261 5         9 my $callback_context = {};
262 5         12 for my $callback_input ( @{ $callback->{Inputs} } ) {
  5         10  
263 5         8 my ( $component_id, $component_property ) = @{$callback_input}{qw(component_id component_property)};
  5         13  
264 5         6 for my $change_input ( @{ $request->{inputs} } ) {
  5         61  
265 5         11 my ( $id, $property, $value ) = @{$change_input}{qw(id property value)};
  5         13  
266 5 50 33     30 if ( $component_id eq $id && $component_property eq $property ) {
267 5         10 push @callback_arguments, $value;
268 5         20 $callback_context->{inputs}{ $id . "." . $property } = $value;
269 5         62 last;
270             }
271             }
272             }
273 5         8 for my $callback_input ( @{ $callback->{State} } ) {
  5         13  
274 1         2 my ( $component_id, $component_property ) = @{$callback_input}{qw(component_id component_property)};
  1         2  
275 1         2 for my $change_input ( @{ $request->{state} } ) {
  1         2  
276 1         3 my ( $id, $property, $value ) = @{$change_input}{qw(id property value)};
  1         3  
277 1 50 33     22 if ( $component_id eq $id && $component_property eq $property ) {
278 1         4 push @callback_arguments, $value;
279 1         7 $callback_context->{states}{ $id . "." . $property } = $value;
280 1         3 last;
281             }
282             }
283             }
284              
285 5         10 $callback_context->{triggered} = [];
286 5         7 for my $triggered_input ( @{ $request->{changedPropIds} } ) {
  5         9  
287 5         32 push @{ $callback_context->{triggered} },
288             { prop_id => $triggered_input,
289 5         7 value => $callback_context->{inputs}{$triggered_input}
290             };
291             }
292 5         9 push @callback_arguments, $callback_context;
293              
294 5         12 my $output_type = ref $callback->{Output};
295 5 100       15 if ( $output_type eq 'ARRAY' ) {
    50          
296 1         7 my @return_value = $callback->{callback}(@callback_arguments);
297 1         14 my $props_updated = {};
298 1         2 my $index_output = 0;
299 1         2 my $some_updated = 0;
300 1         2 for my $output ( @{ $callback->{'Output'} } ) {
  1         2  
301 2         4 my $output_value = $return_value[ $index_output++ ];
302 2 50 33     9 if ( !( Scalar::Util::blessed($output_value) && $output_value->isa('Dash::Internal::NoUpdate') ) ) {
303             $props_updated->{ $output->{component_id} } =
304 2         6 { $output->{component_property} => $output_value };
305 2         5 $some_updated = 1;
306             }
307             }
308 1 50       3 if ($some_updated) {
309 1         4 return { response => $props_updated, multi => JSON::true };
310             } else {
311 0         0 Dash::Exceptions::PreventUpdate->throw;
312             }
313             } elsif ( $output_type eq 'HASH' ) {
314 4         13 my $updated_value = $callback->{callback}(@callback_arguments);
315 3 50 33     27 if ( Scalar::Util::blessed($updated_value) && $updated_value->isa('Dash::Internal::NoUpdate') ) {
316 0         0 Dash::Exceptions::PreventUpdate->throw;
317             }
318 3         10 my $updated_property = ( split( /\./, $request->{output} ) )[-1];
319 3         7 my $props_updated = { $updated_property => $updated_value };
320 3         21 return { response => { props => $props_updated } };
321             } else {
322 0         0 die 'Callback not supported';
323             }
324             } else {
325 0         0 return { response => "There is no matching callback" };
326             }
327              
328             } else {
329 1         4 return { response => "There is no registered callbacks" };
330             }
331 0         0 return { response => "Internal error" };
332             }
333              
334             sub _search_callback {
335 5     5   7 my $self = shift;
336 5         6 my $output = shift;
337              
338 5         11 my $callbacks = $self->_callbacks;
339 5         11 my @matching_callbacks = ( $callbacks->{$output} );
340 5         12 return \@matching_callbacks;
341             }
342              
343             sub _rendered_stylesheets {
344 1     1   9 return '';
345             }
346              
347             sub _render_external_stylesheets {
348 0     0   0 my $self = shift;
349 0         0 my $stylesheets = $self->external_stylesheets;
350 0         0 my $rendered_external_stylesheets = "";
351 0         0 for my $stylesheet (@$stylesheets) {
352 0         0 $rendered_external_stylesheets .= '' . "\n";
353             }
354 0         0 return $rendered_external_stylesheets;
355             }
356              
357             sub _render_and_cache_external_stylesheets {
358 0     0   0 my $self = shift;
359 0         0 my $stylesheets = $self->_render_external_stylesheets();
360 0         0 $self->_rendered_external_stylesheets($stylesheets);
361             }
362              
363             sub _render_and_cache_scripts {
364 0     0   0 my $self = shift;
365 0         0 my $scripts = $self->_render_scripts();
366 0         0 $self->_rendered_scripts($scripts);
367             }
368              
369             sub _render_dash_config {
370 0     0   0 my $self = shift;
371 0         0 my $json = JSON->new->utf8->allow_blessed->convert_blessed;
372 0         0 return '';
373             }
374              
375             sub _dash_renderer_js_dependencies {
376 0     0   0 my $js_dist_dependencies = Dash::Renderer::_js_dist_dependencies();
377 0         0 my @js_deps = ();
378 0         0 for my $deps (@$js_dist_dependencies) {
379 0         0 my $external_url = $deps->{external_url};
380 0         0 my $relative_package_path = $deps->{relative_package_path};
381 0         0 my $namespace = $deps->{namespace};
382 0         0 my $dep_count = 0;
383 0         0 for my $dep ( @{ $relative_package_path->{prod} } ) {
  0         0  
384             my $js_dep = { namespace => $namespace,
385             relative_package_path => $dep,
386             dev_package_path => $relative_package_path->{dev}[$dep_count],
387 0         0 external_url => $external_url->{prod}[$dep_count]
388             };
389 0         0 push @js_deps, $js_dep;
390 0         0 $dep_count++;
391             }
392             }
393 0         0 \@js_deps;
394             }
395              
396             sub _dash_renderer_js_deps {
397 0     0   0 return Dash::Renderer::_js_dist();
398             }
399              
400             sub _render_dash_renderer_script {
401 0     0   0 return '';
402             }
403              
404             sub _render_scripts {
405 0     0   0 my $self = shift;
406              
407             # First dash_renderer dependencies
408 0         0 my $scripts_dependencies = $self->_dash_renderer_js_dependencies;
409              
410             # Traverse layout and recover javascript dependencies
411             # TODO auto register dependencies on component creation to avoid traversing and filter too much dependencies
412 0         0 my $layout = $self->layout;
413              
414 0         0 my $visitor;
415 0         0 my $stack_depth_limit = 1000;
416             $visitor = sub {
417 0     0   0 my $node = shift;
418 0         0 my $stack_depth = shift;
419 0 0       0 if ( $stack_depth++ >= $stack_depth_limit ) {
420              
421             # TODO warn user that layout is too deep
422 0         0 return;
423             }
424 0         0 my $type = ref $node;
425 0 0       0 if ( $type eq 'HASH' ) {
    0          
    0          
426 0         0 for my $key ( keys %$node ) {
427 0         0 $visitor->( $node->{$key}, $stack_depth );
428             }
429             } elsif ( $type eq 'ARRAY' ) {
430 0         0 for my $element (@$node) {
431 0         0 $visitor->( $element, $stack_depth );
432             }
433             } elsif ( $type ne '' ) {
434 0         0 my $node_dependencies = $node->_js_dist();
435 0 0       0 push @$scripts_dependencies, @$node_dependencies if defined $node_dependencies;
436 0 0       0 if ( $node->can('children') ) {
437 0         0 $visitor->( $node->children, $stack_depth );
438             }
439             }
440 0         0 };
441              
442 0         0 $visitor->( $layout, 0 );
443              
444 0         0 my $rendered_scripts = "";
445 0         0 $rendered_scripts .= $self->_render_dash_config();
446 0         0 push @$scripts_dependencies, @{ $self->_dash_renderer_js_deps() };
  0         0  
447 0         0 my $filtered_resources = $self->_filter_resources($scripts_dependencies);
448 0         0 my %rendered = ();
449 0         0 for my $dep (@$filtered_resources) {
450 0   0     0 my $dynamic = $dep->{dynamic} // 0;
451 0 0       0 if ( !$dynamic ) {
452 0         0 my $resource_path_part = join( "/", $dep->{namespace}, $dep->{relative_package_path} );
453 0 0       0 if ( !$rendered{$resource_path_part} ) {
454 0         0 $rendered_scripts .=
455             '' . "\n";
456 0         0 $rendered{$resource_path_part} = 1;
457             }
458             }
459             }
460 0         0 $rendered_scripts .= $self->_render_dash_renderer_script();
461              
462 0         0 return $rendered_scripts;
463             }
464              
465             sub _filter_resources {
466 0     0   0 my $self = shift;
467 0         0 my $resources = shift;
468 0         0 my %params = @_;
469 0   0     0 my $dev_bundles = $params{dev_bundles} // 0;
470 0   0     0 my $eager_loading = $params{eager_loading} // 0;
471 0   0     0 my $serve_locally = $params{serve_locally} // 1;
472              
473 0         0 my $filtered_resources = [];
474 0         0 for my $resource (@$resources) {
475 0         0 my $filtered_resource = {};
476 0         0 my $dynamic = $resource->{dynamic};
477 0 0       0 if ( defined $dynamic ) {
478 0         0 $filtered_resource->{dynamic} = $dynamic;
479             }
480 0         0 my $async = $resource->{async};
481 0 0       0 if ( defined $async ) {
482 0 0       0 if ( defined $dynamic ) {
483 0         0 die "A resource can't have both dynamic and async: " + to_json($resource);
484             }
485 0         0 my $dynamic = 1;
486 0 0       0 if ( $async eq 'lazy' ) {
487 0         0 $dynamic = 1;
488             } else {
489 0 0 0     0 if ( $async eq 'eager' && !$eager_loading ) {
490 0         0 $dynamic = 1;
491             } else {
492 0 0 0     0 if ( $async && !$eager_loading ) {
493 0         0 $dynamic = 1;
494             } else {
495 0         0 $dynamic = 0;
496             }
497             }
498             }
499 0         0 $filtered_resource->{dynamic} = $dynamic;
500             }
501 0         0 my $namespace = $resource->{namespace};
502 0 0       0 if ( defined $namespace ) {
503 0         0 $filtered_resource->{namespace} = $namespace;
504             }
505 0         0 my $external_url = $resource->{external_url};
506 0 0 0     0 if ( defined $external_url && !$serve_locally ) {
507 0         0 $filtered_resource->{external_url} = $external_url;
508             } else {
509 0         0 my $dev_package_path = $resource->{dev_package_path};
510 0 0 0     0 if ( defined $dev_package_path && $dev_bundles ) {
511 0         0 $filtered_resource->{relative_package_path} = $dev_package_path;
512             } else {
513 0         0 my $relative_package_path = $resource->{relative_package_path};
514 0 0       0 if ( defined $relative_package_path ) {
515 0         0 $filtered_resource->{relative_package_path} = $relative_package_path;
516             } else {
517 0         0 my $absolute_path = $resource->{absolute_path};
518 0 0       0 if ( defined $absolute_path ) {
519 0         0 $filtered_resource->{absolute_path} = $absolute_path;
520             } else {
521 0         0 my $asset_path = $resource->{asset_path};
522 0 0       0 if ( defined $asset_path ) {
523 0         0 my $stat_info = path( $resource->{filepath} )->stat;
524 0         0 $filtered_resource->{asset_path} = $asset_path;
525 0         0 $filtered_resource->{ts} = $stat_info->mtime;
526             } else {
527 0 0       0 if ($serve_locally) {
528 0         0 warn
529             'There is no local version of this resource. Please consider using external_scripts or external_stylesheets : '
530             + to_json($resource);
531 0         0 next;
532             } else {
533 0         0 die
534             'There is no relative_package-path, absolute_path or external_url for this resource : '
535             + to_json($resource);
536             }
537             }
538             }
539             }
540             }
541             }
542              
543 0         0 push @$filtered_resources, $filtered_resource;
544             }
545 0         0 return $filtered_resources;
546             }
547              
548             sub _filename_from_file_with_fingerprint {
549 1     1   14 my $self = shift;
550 1         2 my $file = shift;
551 1         5 my @path_parts = split( /\//, $file );
552 1         6 my @name_parts = split( /\./, $path_parts[-1] );
553              
554             # Check if the resource has a fingerprint
555 1 50 33     8 if ( ( scalar @name_parts ) > 2 && $name_parts[1] =~ /^v[\w-]+m[0-9a-fA-F]+$/ ) {
556 0         0 my $original_name = join( ".", $name_parts[0], @name_parts[ 2 .. ( scalar @name_parts - 1 ) ] );
557 0         0 $file = join( "/", @path_parts[ 0 .. ( scalar @path_parts - 2 ) ], $original_name );
558             }
559              
560 1         4 return $file;
561             }
562              
563             1;
564              
565             __END__