File Coverage

blib/lib/WWW/GoodData.pm
Criterion Covered Total %
statement 15 214 7.0
branch 0 94 0.0
condition 0 45 0.0
subroutine 5 33 15.1
pod 19 23 82.6
total 39 409 9.5


line stmt bran cond sub pod time code
1             package WWW::GoodData;
2              
3             =head1 NAME
4              
5             WWW::GoodData - Client library for GoodData REST-ful API
6              
7             =head1 SYNOPSIS
8              
9             use WWW::GoodData;
10             my $gdc = new WWW::GoodData;
11             print $gdc->get_uri ('md', { title => 'My Project' });
12              
13             =head1 DESCRIPTION
14              
15             B is the client for GoodData JSON-based API
16             built atop L client agent, with focus
17             on usefullness and correctness of implementation.
18              
19             It provides code for navigating the REST-ful API structure as well as
20             wrapper funcitons for common actions.
21              
22             =cut
23              
24 1     1   1219 use strict;
  1         2  
  1         41  
25 1     1   6 use warnings;
  1         2  
  1         34  
26              
27 1     1   5 use WWW::GoodData::Agent;
  1         2  
  1         20  
28 1     1   6 use JSON;
  1         2  
  1         10  
29 1     1   124 use URI;
  1         11  
  1         3474  
30              
31             our $VERSION = '1.11';
32             our $root = new URI ('https://secure.gooddata.com/gdc');
33              
34             =head1 METHODS
35              
36             =over 4
37              
38             =item B [PARAMS]
39              
40             Create a new client instance.
41              
42             You can optionally pass a hash reference with properties that would be
43             blessed, otherwise a new one is created. Possible properties include:
44              
45             =over 8
46              
47             =item B
48              
49             A L instance to use.
50              
51             =item B
52              
53             A number of retries to obtain results of asynchronous tasks, such as
54             report exports or data uploads. See B.
55              
56             Defaults to 3600 (delay of one hour).
57              
58             =back
59              
60             =cut
61              
62             sub new
63             {
64 0     0 1   my $class = shift;
65 0   0       my $self = shift || {};
66 0           bless $self, $class;
67 0   0       $self->{agent} ||= new WWW::GoodData::Agent ($root);
68 0   0       $self->{retries} ||= 3600;
69 0           return $self;
70             }
71              
72             # API hierarchy traversal Cache
73             our %links;
74             sub get_canonical_links
75             {
76 0     0 0   my $self = shift;
77 0           my $root = shift;
78 0 0         my @path = map { ref $_ ? $_ : { category => $_ } } @_;
  0            
79 0           my $link = shift @path;
80              
81 0 0         unless ($links{$root}) {
82 0           my $response = $self->{agent}->get ($root);
83             # Various ways to get the links
84 0 0 0       if (exists $response->{about}) {
    0          
    0          
85             # Ordinary structure with about section
86 0           $links{$root} = $response->{about}{links};
87             } elsif (exists $response->{query} and exists $response->{query}{entries}) {
88             # Inconsistent query entries
89 0           $links{$root} = $response->{query}{entries};
90             } elsif (scalar keys %$response == 1) {
91 0           my @elements = ($response);
92 0           my ($structure) = keys %$response;
93              
94             # Aggregated resources (/gdc/account/profile/666/projects)
95 0 0         @elements = @{$response->{$structure}}
  0            
96             if ref $response->{$structure} eq 'ARRAY';
97              
98 0           $links{$root} = [];
99 0           foreach my $element (@elements) {
100 0           my $root = $root;
101 0           my ($type) = keys %$element;
102              
103             # Metadata with interesting information outside "links"
104 0 0 0       if (exists $element->{$type}{links}{self}
105             and exists $element->{$type}{meta}) {
106 0           my $link = new URI ($element->{$type}{links}{self})->abs ($root);
107 0           push @{$links{$root}}, {
  0            
108 0           %{$element->{$type}{meta}},
109             category => $type,
110             structure => $structure,
111             link => $link,
112             };
113 0           $root = $link;
114             }
115              
116             # The links themselves
117 0           foreach my $category (keys %{$element->{$type}{links}}) {
  0            
118 0           my $link = new URI ($element->{$type}{links}{$category})->abs ($root);
119 0           push @{$links{$root}}, {
  0            
120             structure => $structure,
121             category => $category,
122             type => $type,
123             link => $link,
124             };
125             }
126             }
127              
128             } else {
129 0           die 'No links';
130             }
131             }
132              
133             # Canonicalize the links
134 0           $_->{link} = new URI ($_->{link})->abs ($root) foreach @{$links{$root}};
  0            
135              
136 0           my @matches = grep {
137 0           my $this_link = $_;
138             # Filter out those, who lack any of our keys or
139             # hold a different value for it.
140 0 0 0       not map { not exists $link->{$_}
  0 0          
141             or not exists $this_link->{$_}
142             or $link->{$_} ne $this_link->{$_}
143             ? 1 : () } keys %$link
144 0           } @{$links{$root}};
145              
146             # Fully resolved
147 0 0         return @matches unless @path;
148              
149 0 0         die 'Nonexistent component in path' unless @matches;
150 0 0         die 'Ambigious path' unless scalar @matches == 1;
151              
152             # Traverse further
153 0           return $self->get_canonical_links ($matches[0]->{link}, @path);
154             }
155              
156             # This is a 'normalized' version, for convenience and compatibility
157             sub get_links
158             {
159 0     0 0   my $self = shift;
160 0 0 0       my $root = (ref $_[0] and ref $_[0] ne 'HASH') ? shift : '';
161              
162             # Canonicalize URIs
163 0           $root = new URI ($root)->abs ($self->{agent}{root});
164              
165             # And decanonicalize, ommiting the scheme and authority part if possible
166 0           my @links = $self->get_canonical_links ($root, @_);
167             $_->{link} = $_->{link}->rel ($root)->authority
168 0 0         ? $_->{link} : new URI ($_->{link}->path) foreach @links;
169              
170 0           return @links;
171             }
172              
173             =item B PATH
174              
175             Traverse the links in resource hierarchy following given PATH,
176             starting from API root (L by default).
177              
178             PATH is an array of dictionaries, where each key-value pair
179             matches properties of a link. If a plain string is specified,
180             it is considered to be a match against B property:
181              
182             $gdc->links ('md', { 'category' => 'projects' });
183              
184             The above call returns a list of all projects, with links to
185             their metadata resources.
186              
187             =cut
188              
189             sub links
190             {
191 0     0 1   my @links = get_links @_;
192 0 0         return @links if @links;
193 0           %links = ();
194 0           return get_links @_;
195             }
196              
197             =item B PATH
198              
199             Follows the same samentics as B() call, but returns an
200             URI of the first matching resource instead of complete link
201             structure.
202              
203             =cut
204              
205             sub get_uri
206             {
207 0     0 1   [links @_]->[0]{link};
208             }
209              
210             =item B EMAIL PASSWORD
211              
212             Obtain a SST (login token).
213              
214             =cut
215              
216             sub login
217             {
218 0     0 1   my $self = shift;
219 0           my ($login, $password) = @_;
220              
221 0           my $root = new URI ($self->{agent}{root});
222 0           my $staging = $self->get_uri ('uploads')->abs ($root);
223 0           my $netloc = $staging->host.':'.$staging->port;
224              
225 0           $self->{agent}->credentials ($netloc,
226             'GoodData project data staging area', $login => $password);
227              
228 0           $self->{login} = $self->{agent}->post ($self->get_uri ('login'),
229             {postUserLogin => {
230             login => $login,
231             password => $password,
232             remember => 0}});
233             }
234              
235             =item B
236              
237             Make server invalidate the client session and drop
238             credential tokens.
239              
240             Is called upon destruction of the GoodData client instance.
241              
242             =cut
243              
244             sub logout
245             {
246 0     0 1   my $self = shift;
247              
248 0 0         die 'Not logged in' unless defined $self->{login};
249              
250             # Forget Basic authentication
251 0           my $root = new URI ($self->{agent}{root});
252 0           my $staging = $self->get_uri ('uploads')->abs ($root);
253 0           my $netloc = $staging->host.':'.$staging->port;
254 0           $self->{agent}->credentials ($netloc,
255             'GoodData project data staging area', undef, undef);
256              
257             # The redirect magic does not work for POSTs and we can't really
258             # handle 401s until the API provides reason for them...
259 0           $self->{agent}->get ($self->get_uri ('token'));
260              
261 0           $self->{agent}->delete ($self->{login}{userLogin}{state});
262 0           $self->{login} = undef;
263             }
264              
265             =item B OLD NEW
266              
267             Change user password given the old and new password.
268              
269             =cut
270              
271             sub change_passwd
272             {
273 0     0 1   my $self = shift;
274 0 0         my $old_passwd = shift or die 'No old password given';
275 0 0         my $new_passwd = shift or die 'No new password given';
276              
277 0 0         die 'Not logged in' unless defined $self->{login};
278              
279 0           my $profile = $self->{agent}->get ($self->{login}{userLogin}{profile});
280 0           my $new_profile = {
281             'accountSetting' => {
282             'old_password' => $old_passwd,
283             'password' => $new_passwd,
284             'verifyPassword' => $new_passwd,
285             'firstName' => $profile->{accountSetting}->{firstName},
286             'lastName' => $profile->{accountSetting}->{lastName}
287             }
288             };
289              
290 0           $self->{agent}->put ($self->{login}{userLogin}{profile}, $new_profile);
291             }
292              
293             =item B
294              
295             Return array of links to project resources on metadata server.
296              
297             =cut
298              
299             sub projects
300             {
301 0     0 1   my $self = shift;
302 0 0         die 'Not logged in' unless $self->{login};
303 0           $self->get_links (new URI ($self->{login}{userLogin}{profile}),
304             qw/projects project/);
305             }
306              
307             =item B IDENTIFIER
308              
309             Delete a project given its identifier.
310              
311             =cut
312              
313             sub delete_project
314             {
315 0     0 1   my $self = shift;
316 0           my $project = shift;
317              
318             # Instead of directly DELETE-ing the URI gotten, we check
319             # the existence of a project with such link, as a sanity check
320 0 0         my $uri = $self->get_uri (new URI ($project),
321             { category => 'self', type => 'project' }) # Validate it's a project
322             or die "No such project: $project";
323 0           $self->{agent}->delete ($uri);
324             }
325              
326             =item B TITLE SUMMARY TEMPLATE DRIVER TOKEN
327              
328             Create a project given its title and optionally summary, project template,
329             db engine driver and authorization token
330             return its identifier.
331              
332             The list of valid project templates is available from the template server:
333             L.
334              
335             Valid db engine drivers are 'Pg' (default) and 'mysql'.
336              
337             =cut
338              
339             sub create_project
340             {
341 0     0 1   my $self = shift;
342 0 0         my $title = shift or die 'No title given';
343 0   0       my $summary = shift || '';
344 0           my $template = shift;
345 0           my $driver= shift;
346 0           my $token = shift;
347              
348             # The redirect magic does not work for POSTs and we can't really
349             # handle 401s until the API provides reason for them...
350 0           $self->{agent}->get ($self->get_uri ('token'));
351              
352 0 0         return $self->{agent}->post ($self->get_uri ('projects'), {
    0          
    0          
353             project => {
354             content => {
355             # No hook to override this; use web UI
356             guidedNavigation => 1,
357             ($driver ? (driver => $driver) : ()),
358             ($token ? (authorizationToken => $token) : ())
359             },
360             meta => {
361             summary => $summary,
362             title => $title,
363             ($template ? (projectTemplate => $template) : ()),
364             }
365             }})->{uri};
366             }
367              
368             =item B DOMAIN EMAIL LOGIN PASSWORD FIRST_NAME LAST_NAME PHONE COMPANY SSO_PROVIDER
369              
370             Create a user given its email, login, password, first name, surname, phone and optionally company,
371             sso provider in domain.
372              
373             Returns user identifier (URI).
374              
375             =cut
376              
377             sub create_user
378             {
379 0     0 1   my $self = shift;
380 0   0       my $domain_uri = shift || die "No domain specified";
381 0   0       my $email = shift || die "Email must be specified";
382 0   0       my $login = shift || $email;
383 0           my $passwd = shift;
384 0           my $firstname = shift;
385 0           my $lastname = shift;
386 0           my $phone = shift;
387 0   0       my $company = shift || '';
388 0           my $sso_provider = shift;
389              
390 0 0         return $self->{agent}->post ($domain_uri."/users", { #TODO links does not exists in REST API
391             accountSetting => {
392             login => $login,
393             email => $email,
394             password => $passwd,
395             verifyPassword => $passwd,
396             firstName => $firstname,
397             lastName => $lastname,
398             phoneNumber => $phone,
399             companyName => $company,
400             ($sso_provider ? (ssoProvider => $sso_provider) : ()),
401             }})->{uri};
402             }
403              
404             =item B PROJECT
405              
406             Gets project roles.
407              
408             Return array of project roles.
409              
410             =cut
411              
412             sub get_roles
413             {
414 0     0 1   my $self = shift;
415 0           my $project = shift;
416              
417 0           return $self->{agent}->get (
418             $self->get_uri (new URI($project), 'roles'))->{projectRoles}{roles};
419             }
420             =item B PROJECT
421              
422             Return array of links to repoort resources on metadata server.
423              
424             =cut
425              
426             sub reports
427             {
428 0     0 0   my $self = shift;
429 0           my $project = shift;
430              
431 0 0         die 'Not logged in' unless $self->{login};
432 0           $self->get_links (new URI ($project),
433             { category => 'self', type => 'project' }, # Validate it's a project
434             qw/metadata query reports/, {});
435             }
436              
437             =item B REPORT
438              
439             Trigger a report computation and return the URI of the result resource.
440              
441             =cut
442              
443             sub compute_report
444             {
445 0     0 1   my $self = shift;
446 0           my $report = shift;
447              
448 0           return $self->{agent}->post (
449             $self->get_uri (qw/xtab xtab-executor3/),
450             { report_req => { report => ''.$report }}
451             );
452             }
453              
454             =item B REPORT FORMAT
455              
456             Submit an exporter task for a computed report (see B),
457             wait for completion and return raw data in desired format.
458              
459             =cut
460              
461             sub export_report
462             {
463 0     0 1   my $self = shift;
464 0           my $report = shift;
465 0           my $format = shift;
466              
467             # Compute the report
468 0           my $result = $self->{agent}->post (
469             $self->get_uri (qw/report-exporter exporter-executor/),
470             { result_req => { format => $format,
471             result => $self->compute_report ($report) }}
472             );
473              
474             # This is for new release, where location is finally set correctly;
475 0 0         $result = $result->{uri} if ref $result eq 'HASH';
476              
477             # Trigger the export
478             my $exported = $self->poll (
479 0     0     sub { $self->{agent}->get ($result) },
480 0 0 0 0     sub { $_[0] and exists $_[0]->{raw} and $_[0]->{raw} ne 'null' }
481 0 0         ) or die 'Timed out waiting for report to export';
482              
483             # Follow the link
484 0 0         $exported = $self->{agent}->get ($exported->{uri}) if exists $exported->{uri};
485              
486             # Gotten the correctly coded result?
487 0           my $wanted = $format;
488 0           my %compat = (
489             png => 'image/png',
490             pdf => 'application/pdf',
491             xls => 'application/vnd.ms-excel',
492             csv => 'text/csv',
493             );
494 0 0         $wanted = $compat{$wanted} if exists $compat{$wanted};
495 0 0         return $exported->{raw} if $exported->{type} =~ /^$wanted/;
496              
497 0           die 'Wrong type of content returned';
498             }
499              
500             =item B PROJECT
501              
502             Return picture of Logical Data Model (LDM) in PNG format.
503              
504             =cut
505              
506             sub ldm_picture
507             {
508 0     0 1   my $self = shift;
509 0           my $project = shift;
510              
511 0           my $model = $self->{agent}->get ($self->{agent}->get (
512             $self->get_uri (new URI ($project),
513             { category => 'ldm' }))->{uri});
514 0 0         die 'Expected PNG image' unless $model->{type} eq 'image/png';
515              
516 0           return $model->{raw};
517             }
518              
519             =item B PROJECT MAQL
520              
521             Execute MAQL statement for a project.
522              
523             =cut
524              
525             sub ldm_manage
526             {
527 0     0 1   my $self = shift;
528 0           my $project = shift;
529 0           my $maql = shift;
530              
531 0           $maql = "# WWW::GoodData MAQL execution\n$maql";
532 0           chomp $maql;
533              
534 0           $self->{agent}->post (
535             $self->get_uri (new URI ($project), qw/metadata ldm ldm-manage/),
536             { manage => { maql => $maql }});
537             }
538              
539             =item B PROJECT MANIFEST
540              
541             Upload and integrate a new data load via Single Loading Interface (SLI).
542              
543             =cut
544              
545             sub upload
546             {
547 0     0 1   my $self = shift;
548 0           my $project = shift;
549 0           my $file = shift;
550              
551             # Parse the manifest
552 0           my $upload_info = decode_json (slurp_file ($file));
553 0 0         die "$file: not a SLI manifest"
554             unless $upload_info->{dataSetSLIManifest};
555              
556             # Construct unique URI in staging area to upload to
557 0           my $uploads = new URI ($self->get_uri ('uploads'));
558 0           $uploads->path_segments ($uploads->path_segments,
559             $upload_info->{dataSetSLIManifest}{dataSet}.'-'.time);
560 0           $self->{agent}->request (new HTTP::Request (MKCOL => $uploads));
561              
562             # Upload the manifest
563 0           my $manifest = $uploads->clone;
564 0           $manifest->path_segments ($manifest->path_segments, 'upload_info.json');
565 0           $self->{agent}->request (new HTTP::Request (PUT => $manifest,
566             ['Content-Type' => 'application/json'], encode_json ($upload_info)));
567              
568             # Upload CSV
569 0           my $csv = $uploads->clone;
570 0           $csv->path_segments ($csv->path_segments, $upload_info->{dataSetSLIManifest}{file});
571 0   0       $self->{agent}->request (new HTTP::Request (PUT => $csv,
572             ['Content-Type' => 'application/csv'],
573             (slurp_file ($upload_info->{dataSetSLIManifest}{file})
574             || die 'No CSV file specified in SLI manifest')));
575              
576             # Trigger the integration
577 0           my $task = $self->{agent}->post (
578             $self->get_uri (new URI ($project),
579             { category => 'self', type => 'project' }, # Validate it's a project
580             qw/metadata etl pull/),
581             { pullIntegration => [$uploads->path_segments]->[-1] }
582             )->{pullTask}{uri};
583              
584             # Wait for the task to enter a stable state
585             my $result = $self->poll (
586 0     0     sub { $self->{agent}->get ($task) },
587 0     0     sub { shift->{taskStatus} !~ /^(RUNNING|PREPARED)$/ }
588 0 0         ) or die 'Timed out waiting for integration to finish';
589              
590 0 0         return if $result->{taskStatus} eq 'OK';
591 0 0         warn 'Upload finished with warnings' if $result->{taskStatus} eq 'WARNING';
592 0           die 'Upload finished with '.$result->{taskStatus}.' status';
593             }
594              
595             =item B BODY CONDITION
596              
597             Should only be used internally.
598              
599             Run BODY passing its return value to call to CONDITION until it
600             evaluates to true or B (see properties) times out.
601              
602             Returns value is of last iteration of BODY in case
603             CONDITION succeeds, otherwise undefined (in case of timeout).
604              
605             =cut
606              
607             sub poll
608             {
609 0     0 1   my $self = shift;
610 0           my ($body, $cond) = @_;
611 0           my $retries = $self->{retries};
612              
613 0           while ($retries--) {
614 0           my $ret = $body->();
615 0 0         return $ret if $cond->($ret);
616 0           sleep 1;
617             }
618              
619 0           return undef;
620             }
621              
622             =item B PROJECT URI TYPE TITLE SUMMARY EXPRESSION
623              
624             Create a new metadata object of type TYPE with EXPRESSION as the only content.
625              
626             =cut
627              
628             sub create_object_with_expression
629             {
630 0     0 1   my $self = shift;
631 0           my $project = shift;
632 0           my $uri = shift;
633 0 0         my $type = shift or die 'No type given';
634 0 0         my $title = shift or die 'No title given';
635 0   0       my $summary = shift || '';
636 0 0         my $expression = shift or die 'No expression given';
637              
638 0 0         if (defined $uri) {
639 0           $uri = new URI ($uri);
640             } else {
641 0           $uri = $self->get_uri (new URI ($project), qw/metadata obj/);
642             }
643              
644 0           return $self->{agent}->post (
645             $uri,
646             { $type => {
647             content => {
648             expression => $expression
649             },
650             meta => {
651             summary => $summary,
652             title => $title,
653             }
654             }}
655             )->{uri};
656             }
657              
658             =item B PROJECT URI TITLE SUMMARY METRICS DIM FILTERS
659              
660             Create a new reportDefinition in metadata.
661              
662             =cut
663              
664             sub create_report_definition
665             {
666 0     0 1   my $self = shift;
667 0           my $project = shift;
668 0           my $uri = shift;
669 0 0         my $title = shift or die 'No title given';
670 0   0       my $summary = shift || '';
671 0   0       my $metrics = shift || [];
672 0   0       my $dim = shift || [];
673 0   0       my $filters = shift || [];
674              
675 0 0         if (defined $uri) {
676 0           $uri = new URI ($uri);
677             } else {
678 0           $uri = $self->get_uri (new URI ($project), qw/metadata obj/);
679             }
680              
681 0           return $self->{agent}->post (
682             $uri,
683             { reportDefinition => {
684             content => {
685             filters => [ map +{ expression => $_ }, @$filters ],
686             grid => {
687             columns => [ "metricGroup" ],
688             metrics => [ map +{ alias => '', uri => $_ }, @$metrics ],
689             rows => [ map +{ attribute => { alias => '', uri => $_,
690             totals => [[]] } }, @$dim ],
691             sort => {
692             columns => [],
693             rows => [],
694             },
695             columnWidths => []
696             },
697             format => "grid"
698             },
699             meta => {
700             summary => $summary,
701             title => $title,
702             }
703             }}
704             )->{uri};
705             }
706              
707             =item B
708              
709             Log out the session with B unless not logged in.
710              
711             =cut
712              
713             sub DESTROY
714             {
715 0     0     my $self = shift;
716 0 0         $self->logout if $self->{login};
717             }
718              
719             sub slurp_file
720             {
721 0     0 0   my $file = shift;
722 0 0         open (my $fh, '<', $file) or die "$file: $!";
723 0           return join '', <$fh>;
724             }
725              
726             =back
727              
728             =head1 SEE ALSO
729              
730             =over
731              
732             =item *
733              
734             L -- API documentation
735              
736             =item *
737              
738             L -- Browsable GoodData API
739              
740             =item *
741              
742             L -- GoodData API-aware user agent
743              
744             =back
745              
746             =head1 COPYRIGHT
747              
748             Copyright 2011, 2012, 2013 Lubomir Rintel
749              
750             Copyright 2012, 2013 Adam Stulpa, Jan Orel, Tomas Janousek
751              
752             This program is free software; you can redistribute it and/or modify it
753             under the same terms as Perl itself.
754              
755             =head1 AUTHORS
756              
757             Lubomir Rintel C
758              
759             Adam Stulpa C
760              
761             Jan Orel C
762              
763             Tomas Janousek C
764              
765             =cut
766              
767             1;