send ASCII-only requests to Stemweb. Issue #46
[scpubgit/stemmaweb.git] / lib / stemmaweb / Controller / Stemweb.pm
index 8804c4c..262f235 100644 (file)
@@ -2,16 +2,33 @@ package stemmaweb::Controller::Stemweb;
 use Moose;
 use namespace::autoclean;
 use Encode qw/ decode_utf8 /;
+use File::Which;
 use JSON;
+use List::Util qw/ max /;
 use LWP::UserAgent;
 use Safe::Isa;
+use Scalar::Util qw/ looks_like_number /;
+use Text::Tradition::StemmaUtil qw/ character_input phylip_pars /;
 use TryCatch;
 use URI;
 
 BEGIN { extends 'Catalyst::Controller' }
 
-## TODO Move the /algorithms/available function to the Stemweb module
-my $STEMWEB_BASE_URL = 'http://slinkola.users.cs.helsinki.fi';
+has stemweb_url => (
+       is => 'ro',
+       isa => 'Str',
+       default => 'http://slinkola.users.cs.helsinki.fi',
+       );
+       
+has pars_path => (
+       is => 'ro',
+       isa => 'Str',
+       );
+       
+has pars_pk => (
+       is => 'rw',
+       isa => 'Int',
+       );
 
 =head1 NAME
 
@@ -52,7 +69,7 @@ sub result :Local :Args(0) {
                        # Read in the file and parse that.
                        $c->log->debug( "Request body is in a temp file" );
                        open( POSTDATA, $c->request->body ) 
-                               or return _json_error( 500, "Failed to open post data file" );
+                               or return _json_error( $c, 500, "Failed to open post data file" );
                        binmode( POSTDATA, ':utf8' );
                        # JSON should be all one line
                        my $pdata = <POSTDATA>;
@@ -75,6 +92,44 @@ sub result :Local :Args(0) {
        }
 }
 
+=head2 available
+
+ GET algorithms/available
+Queries the Stemweb server for available stemma generation algorithms and their 
+parameters. Returns the JSON answer as obtained from Stemweb.
+
+=cut
+
+sub available :Local :Args(0) {
+       my( $self, $c ) = @_;
+       my $ua = LWP::UserAgent->new();
+       my $resp = $ua->get( $self->stemweb_url . '/algorithms/available' );
+       my $parameters = [];
+       if( $resp->is_success ) {
+               $parameters = decode_json( $resp->content );
+       } # otherwise we have no available algorithms.
+       ## Temporary HACK: run Pars too
+       if( $self->_has_pars ) {
+               # Use the highest passed primary key + 1
+               my $parspk = max( map { $_->{pk} } 
+                       grep { $_->{model} eq 'algorithms.algorithm' } @$parameters ) + 1;
+               # Add Pars as an algorithm
+               $self->pars_pk( $parspk );
+               push( @$parameters, {
+                       pk => $parspk,
+                       model => 'algorithms.algorithm',
+                       fields => {
+                               args => [],
+                               name => 'Pars',
+                               desc => 'The program "pars", from the Phylip bio-statistical software package, produces a maximum-parsimony distance tree of the witnesses. More information on maximum parsimony can be found <a href="https://wiki.hiit.fi/display/stemmatology/Maximum+parsimony">here</a>. Please note that Phylip "pars" only supports a maximum of eight variants readings in any one variant location in the text. If your text displays more divergence than this at any point, please consider disregarding orthographic and spelling variation below, or use one of the other algorithms.'
+                       }
+               });
+       }
+       $c->stash->{result} = $parameters;
+       $c->forward('View::JSON');
+}
+
 =head2 query
 
  GET stemweb/query/<jobid>
@@ -88,7 +143,7 @@ indicate that the job is still running.
 sub query :Local :Args(1) {
        my( $self, $c, $jobid ) = @_;
        my $ua = LWP::UserAgent->new();
-       my $resp = $ua->get( $STEMWEB_BASE_URL . "/algorithms/jobstatus/$jobid" );
+       my $resp = $ua->get( $self->stemweb_url . "/algorithms/jobstatus/$jobid" );
        if( $resp->is_success ) {
                # Process it
                my $response = decode_utf8( $resp->content );
@@ -115,24 +170,17 @@ sub query :Local :Args(1) {
 ## Helper function for parsing Stemweb result data either by push or by pull
 sub _process_stemweb_result {
        my( $c, $answer ) = @_;
-       # Find a tradition with the defined Stemweb job ID.
-       # TODO: Maybe get Stemweb to pass back the tradition ID...
+       # Find the specified tradition and check its job ID.
        my $m = $c->model('Directory');
-       my @traditions;
-       ## STUPID HACK: unless we load the possible tradition owners
-       ## within scope of the scan, they will not exist when the affected
-       ## tradition is saved.
-       my @users;
-       $m->scan( sub{ push( @traditions, $_[0] )
-                                       if $_[0]->$_isa('Text::Tradition')
-                                       && $_[0]->has_stemweb_jobid 
-                                       && $_[0]->stemweb_jobid eq $answer->{jobid}; 
-                       push( @users, $_[0] ) if $_[0]->$_isa('Text::Tradition::User');
-                               } );
-       if( @traditions == 1 ) {
-               my $tradition = shift @traditions;
-               if( $answer->{status} == 0 ) {
-                       my $stemmata;
+       my $tradition = $m->tradition( $answer->{textid} );
+       unless( $tradition ) {
+               return _json_error( $c, 400, "No tradition found with ID "
+                       . $answer->{textid} );
+       }
+       if( $answer->{status} == 0 ) {
+               my $stemmata;
+               if( $tradition->has_stemweb_jobid 
+                       && $tradition->stemweb_jobid eq $answer->{jobid} ) {
                        try {
                                $stemmata = $tradition->record_stemweb_result( $answer );
                                $m->save( $tradition );
@@ -141,7 +189,14 @@ sub _process_stemweb_result {
                        } catch {
                                return _json_error( $c, 500, $@ );
                        }
+               } else {
+                       # It may be that we already received a callback meanwhile.
+                       # Check all stemmata for the given jobid and return them.
+                       @$stemmata = grep { $_->came_from_jobid && $_->from_jobid eq $answer->{jobid} } $tradition->stemmata;
+               }
+               if( @$stemmata ) {
                        # If we got here, success!
+                       # TODO Use helper in Root.pm to do this
                        my @steminfo = map { { 
                                        name => $_->identifier, 
                                        directed => _json_bool( !$_->is_undirected ),
@@ -150,23 +205,22 @@ sub _process_stemweb_result {
                        $c->stash->{'result'} = { 
                                'status' => 'success',
                                'stemmata' => \@steminfo };
-               } elsif( $answer->{status} < 1 ) {
-                       $c->stash->{'result'} = { 'status' => 'running' };
                } else {
-                       return _json_error( $c, 500,
-                               "Stemweb failure not handled: " . $answer->{result} );
+                       # Hm, no stemmata found on this tradition with this jobid.
+                       # Clear the tradition jobid so that the user can try again.
+                       if( $tradition->has_stemweb_jobid ) {
+                               $tradition->_clear_stemweb_jobid;
+                               $m->save( $tradition );
+                       }
+                       $c->stash->{'result'} = { status => 'notfound' };
                }
-       } elsif( @traditions ) {
-               return _json_error( $c, 500, 
-                       "Multiple traditions with Stemweb job ID " . $answer->{jobid} . "!" );
+       } elsif( $answer->{status} == 1 ) {
+               $c->stash->{'result'} = { 'status' => 'running' };
        } else {
-               # Possible that the tradition got updated in the meantime...
-               if( $answer->{status} == 0 ) {
-                       $c->stash->{'result'} = { 'status' => 'notfound' };
-               } else {
-                       return _json_error( $c, 400, 
-                               "No tradition found with Stemweb job ID " . $answer->{jobid} );
-               }
+               # Failure. Clear the job ID so that the user can try again.
+               $tradition->_clear_stemweb_jobid;
+               $m->save( $tradition );
+               $c->stash->{'result'} = { 'status' => 'failed', 'message' => $answer->{result} };
        }
        $c->forward('View::JSON');
 }
@@ -195,43 +249,72 @@ sub request :Local :Args(0) {
                        'You do not have permission to update stemmata for this tradition' ) )
                unless $ok eq 'full';
        
-       # Form the request for Stemweb.
        my $algorithm = delete $reqparams->{algorithm};
-       my $return_uri = URI->new( $c->uri_for( '/stemweb/result' ) );
-       my $stemweb_request = {
-               return_path => $return_uri->path,
-               return_host => $return_uri->host_port,
-               data => $t->collation->as_tsv({noac => 1}),
-               userid => $c->user->get_object->email,
-               textid => $tid,
-               parameters => $reqparams };
-               
-       # Call to the appropriate URL with the request parameters.
-       my $ua = LWP::UserAgent->new();
-       $c->log->debug( 'Sending request to Stemweb: ' . to_json( $stemweb_request ) ); 
-       my $resp = $ua->post( $STEMWEB_BASE_URL . "/algorithms/process/$algorithm/",
-               'Content-Type' => 'application/json; charset=utf-8', 
-               'Content' => encode_json( $stemweb_request ) ); 
-       if( $resp->is_success ) {
-               # Process it
-               $c->log->debug( 'Got a response from the server: '
-                       . decode_utf8( $resp->content ) );
-               my $stemweb_response = decode_json( $resp->content );
+       my $mergetypes = delete $reqparams->{merge_reltypes};
+       if( $self->_has_pars && $algorithm == $self->pars_pk ) {
+               my $start_time = scalar( gmtime( time() ) );
+               $t->set_stemweb_jobid( 'local' );
+               my $cdata = character_input( $t, { collapse => $mergetypes } );
+               my $newick;
                try {
-                       $t->set_stemweb_jobid( $stemweb_response->{jobid} );
-               } catch( Text::Tradition::Error $e ) {
-                       return _json_error( $c, 429, $e->message );
+                       $newick = phylip_pars( $cdata, { parspath => $self->_has_pars } );
+               } catch ( Text::Tradition::Error $e ) {
+                       return _json_error( $c, 503, "Parsimony tree generation failed: "
+                               . $e->message );
                }
-               $c->model('Directory')->save( $t );
-               $c->stash->{'result'} = $stemweb_response;
-               $c->forward('View::JSON');
-       } elsif( $resp->code == 500 && $resp->header('Client-Warning')
-               && $resp->header('Client-Warning') eq 'Internal response' ) {
-               # The server was unavailable.
-               return _json_error( $c, 503, "The Stemweb server is currently unreachable." );
+               # We have a result, so form an answer to process.
+               my $answer = {
+                       status => 0,
+                       algorithm => 'pars',
+                       'format' => 'newick',
+                       textid => $tid,
+                       jobid => 'local',
+                       result => $newick,
+                       start_time => $start_time
+               };
+               return _process_stemweb_result( $c, $answer );
        } else {
-               return _json_error( $c, 500, "Stemweb error: " . $resp->code . " / "
-                       . $resp->content );
+               # Form the request for Stemweb.
+               my $return_uri = URI->new( $c->uri_for( '/stemweb/result' ) );
+               my $tsv_options = { noac => 1, ascii => 1 };
+               if( $mergetypes && @$mergetypes ) {
+                       $tsv_options->{mergetypes} = $mergetypes;
+               }
+               my $stemweb_request = {
+                       return_path => $return_uri->path,
+                       return_host => $return_uri->host_port,
+                       data => $t->collation->as_tsv( $tsv_options ),
+                       userid => $c->user->get_object->email,
+                       textid => $tid,
+                       parameters => _cast_nonstrings( $reqparams ) };
+               
+               # Call to the appropriate URL with the request parameters.
+               my $ua = LWP::UserAgent->new();
+               # $c->log->debug( 'Sending request to Stemweb: ' . to_json( $stemweb_request ) ); 
+               my $resp = $ua->post( $self->stemweb_url . "/algorithms/process/$algorithm/",
+                       'Content-Type' => 'application/json; charset=utf-8', 
+                       'Content' => encode_json( $stemweb_request ) ); 
+               if( $resp->is_success ) {
+                       # Process it
+                       $c->log->debug( 'Got a response from the server: '
+                               . decode_utf8( $resp->content ) );
+                       my $stemweb_response = decode_json( $resp->content );
+                       try {
+                               $t->set_stemweb_jobid( $stemweb_response->{jobid} );
+                       } catch( Text::Tradition::Error $e ) {
+                               return _json_error( $c, 429, $e->message );
+                       }
+                       $c->model('Directory')->save( $t );
+                       $c->stash->{'result'} = $stemweb_response;
+                       $c->forward('View::JSON');
+               } elsif( $resp->code == 500 && $resp->header('Client-Warning')
+                       && $resp->header('Client-Warning') eq 'Internal response' ) {
+                       # The server was unavailable.
+                       return _json_error( $c, 503, "The Stemweb server is currently unreachable." );
+               } else {
+                       return _json_error( $c, 500, "Stemweb error: " . $resp->code . " / "
+                               . $resp->content );
+               }
        }
 }
 
@@ -251,6 +334,20 @@ sub _check_permission {
        return _json_error( $c, 403, 'You do not have permission to view this tradition.' );
 }
 
+# QUICK HACK to deal with strict Stemweb validation.
+sub _cast_nonstrings {
+       my $params = shift;
+       foreach my $k ( keys %$params ) {
+               my $v = $params->{$k};
+               if( looks_like_number( $v ) ) {
+                       $params->{$k} = $v * 1;
+               } elsif ( !defined $v || $v eq 'true' ) {
+                       $params->{$k} = _json_bool( $v );
+               }
+       }
+       return $params;
+}
+
 # Helper to throw a JSON exception
 sub _json_error {
        my( $c, $code, $errmsg ) = @_;
@@ -258,11 +355,16 @@ sub _json_error {
        $c->stash->{'result'} = { 'error' => $errmsg };
        $c->forward('View::JSON');
        return 0;
-}
+}              
 
 sub _json_bool {
        return $_[0] ? JSON::true : JSON::false;
 }
 
+sub _has_pars {
+       my $self = shift;
+       return $self->pars_path || which('pars');
+}
+
 
 1;