make Stemweb listener for result notification (#27) Relies on tla/stemmatology@79c81b...
tla [Mon, 28 Oct 2013 15:32:49 +0000 (16:32 +0100)]
lib/stemmaweb.pm
lib/stemmaweb/Controller/Stemweb.pm [new file with mode: 0644]
script/maketestdb.pl
t/controller_Stemweb.t [new file with mode: 0644]
t/data/besoin_stemweb.dot [new file with mode: 0644]

index 7b52ebd..4a8f77a 100644 (file)
@@ -23,7 +23,6 @@ use Search::GIN::Extract::Multiplex;
 use Catalyst qw/
     ConfigLoader
     Static::Simple
-    Unicode::Encoding
     Authentication
     Session
     Session::Store::File
@@ -49,6 +48,8 @@ __PACKAGE__->config(
     name => 'stemmaweb',
     # Disable deprecated behavior needed by old applications
     disable_component_resolution_regex_fallback => 1,
+    # Set Unicode as the default
+    encoding => 'UTF-8',
     default_view => 'TT',
        'View::JSON' => {
                expose_stash => 'result',
diff --git a/lib/stemmaweb/Controller/Stemweb.pm b/lib/stemmaweb/Controller/Stemweb.pm
new file mode 100644 (file)
index 0000000..8aa8786
--- /dev/null
@@ -0,0 +1,105 @@
+package stemmaweb::Controller::Stemweb;
+use Moose;
+use namespace::autoclean;
+use JSON qw/ from_json /;
+use Safe::Isa;
+use TryCatch;
+
+BEGIN { extends 'Catalyst::Controller' }
+
+=head1 NAME
+
+stemmaweb::Controller::Stemweb - Client listener for Stemweb results
+
+=head1 DESCRIPTION
+
+This is a client listener for the Stemweb API as implemented by the protocol defined at
+L<https://docs.google.com/document/d/1aNYGAo1v1WPDZi6LXZ30FJSMJwF8RQPYbOkKqHdCZEc/pub>.
+
+=head1 METHODS
+
+=head2 result
+
+ POST stemweb/result
+ Content-Type: application/json
+ (On success):
+ { job_id: <ID number>
+   status: 0
+   format: <format>
+   result: <data> }
+ (On failure):
+ { jobid: <ID number>
+   status: >1
+   result: <error message> }
+   
+Used by the Stemweb server to notify us that one or more stemma graphs
+has been calculated in response to an earlier request.
+
+=cut
+
+sub result :Local :Args(0) {
+       my( $self, $c ) = @_;
+       if( $c->request->method eq 'POST' ) {
+               # TODO: Verify the sender!
+               my $answer;
+               if( ref( $c->request->body ) eq 'File::Temp' ) {
+                       # Read in the file and parse that.
+                       open( POSTDATA, $c->request->body ) or die "Failed to open post data file";
+                       binmode( POSTDATA, ':utf8' );
+                       # JSON should be all one line
+                       my $pdata = <POSTDATA>;
+                       chomp $pdata;
+                       close POSTDATA;
+                       $answer = from_json( $pdata );
+               } else {
+                       $answer = from_json( $c->request->body );
+               }
+               # Find a tradition with the defined Stemweb job ID.
+               # TODO: Maybe get Stemweb to pass back the tradition ID...
+               my $m = $c->model('Directory');
+               my @traditions;
+               $m->scan( sub{ push( @traditions, $_[0] )
+                                               if $_[0]->$_isa('Text::Tradition')
+                                               && $_[0]->has_stemweb_jobid 
+                                               && $_[0]->stemweb_jobid eq $answer->{job_id}; 
+                                       } );
+               if( @traditions == 1 ) {
+                       my $tradition = shift @traditions;
+                       if( $answer->{status} == 0 ) {
+                               try {
+                                       $tradition->record_stemweb_result( $answer );
+                                       $m->save( $tradition );
+                               } catch( Text::Tradition::Error $e ) {
+                                       return _json_error( $c, 500, $e->message );
+                               } catch {
+                                       return _json_error( $c, 500, $@ );
+                               }
+                               # If we got here, success!
+                               $c->stash->{'result'} = { 'status' => 'success' };
+                               $c->forward('View::JSON');
+                       } else {
+                               return _json_error( $c, 500,
+                                       "Stemweb failure not handled: " . $answer->{result} );
+                       }
+               } elsif( @traditions ) {
+                       return _json_error( $c, 500, 
+                               "Multiple traditions with Stemweb job ID " . $answer->{job_id} . "!" );
+               } else {
+                       return _json_error( $c, 400, 
+                               "No tradition found with Stemweb job ID " . $answer->{job_id} );
+               }
+       } else {
+               return _json_error( $c, 403, 'Please use POST!' );
+       }
+}
+
+# Helper to throw a JSON exception
+sub _json_error {
+       my( $c, $code, $errmsg ) = @_;
+       $c->response->status( $code );
+       $c->stash->{'result'} = { 'error' => $errmsg };
+       $c->forward('View::JSON');
+       return 0;
+}
+
+1;
\ No newline at end of file
index 387eeba..389b8ec 100755 (executable)
@@ -39,6 +39,7 @@ say "Created users";
 
 my $t1 = Text::Tradition->new( input => 'Self', file => 't/data/besoin.xml' );
 die "Failed to create test tradition #1" unless $t1;
+$t1->add_stemma( dotfile => 't/data/besoin_stemweb.dot' );
 $user->add_tradition( $t1 );
 $dir->store( $user );
 say "Created test user tradition";
diff --git a/t/controller_Stemweb.t b/t/controller_Stemweb.t
new file mode 100644 (file)
index 0000000..49b1023
--- /dev/null
@@ -0,0 +1,148 @@
+#!/usr/bin/env perl
+
+use strict;
+use warnings;
+use feature 'say';
+use HTTP::Request::Common;
+use JSON qw/ decode_json /;
+use Test::More;
+use Test::WWW::Mechanize::Catalyst;
+use Text::Tradition::Directory;
+
+use Catalyst::Test 'stemmaweb';
+use LWP::UserAgent;
+
+eval { no warnings; $DB::deep = 1000; };
+
+# Set up the test data
+use vars qw( $orig_db $was_link );
+my $textids;
+my $dbfile = 'db/traditions.db';
+( $orig_db, $was_link, $textids ) = _make_testing_database();
+
+# Here is the test data we will use
+my $answer = '{"status": 0, "job_id": "4", "algorithm": "RHM", "format": "newick", "start_time": "2013-10-26 10:44:14.050263", "result": "((((((((((((F,U),V),S),T1),T2),A),J),B),L),D),M),C);\n", "end_time": "2013-10-26 10:45:55.398944"}';
+
+# First try without a job ID in the database anywhere
+my $unclaimed_request = request POST '/stemweb/result',
+       'Content-Type' => 'application/json',
+       'Content' => $answer;
+like( $unclaimed_request->header('Content-Type'), qr/application\/json/,
+       "Returned JSON answer for unclaimed request" );
+is( $unclaimed_request->code, 400, "No tradition found with given job ID" );
+like( $unclaimed_request->content, qr/No tradition found with Stemweb job ID/,
+       "Correct error message returned" );
+
+# Now add the relevant job ID to two traditions and test for that error
+{
+       my $dsn = "dbi:SQLite:dbname=$dbfile";
+       my $dir = Text::Tradition::Directory->new( 'dsn' => $dsn );
+       my $scope = $dir->new_scope();
+       my $t1 = $dir->lookup( $textids->{'public'} );
+       $t1->set_stemweb_jobid( '4' ); 
+       $dir->save( $t1 );
+       my $t2 = $dir->lookup( $textids->{'private'} );
+       $t2->set_stemweb_jobid( '4' ); 
+       $dir->save( $t2 );
+}
+# Now try with the job ID in more than one place in the database
+my $oversubscribed_request = request POST '/stemweb/result',
+       'Content-Type' => 'application/json',
+       'Content' => $answer;
+like( $oversubscribed_request->header('Content-Type'), qr/application\/json/,
+       "Returned JSON answer for oversubscribed request" );
+is( $oversubscribed_request->code, 500, "Multiple traditions found with given job ID" );
+like( $oversubscribed_request->content, qr/Multiple traditions with Stemweb job ID/,
+       "Correct error message returned" );
+
+# Finally, try with the job ID on only one tradition.
+{
+       my $dsn = "dbi:SQLite:dbname=$dbfile";
+       my $dir = Text::Tradition::Directory->new( 'dsn' => $dsn );
+       my $scope = $dir->new_scope();
+       my $t2 = $dir->lookup( $textids->{'private'} );
+       $t2->_clear_stemweb_jobid; 
+       $dir->save( $t2 );
+}
+my $expected_request = request POST '/stemweb/result',
+       'Content-Type' => 'application/json',
+       'Content' => $answer;
+like( $expected_request->header('Content-Type'), qr/application\/json/,
+       "Returned JSON answer for expected request" );
+is( $expected_request->code, 200, "Request processed successfully" );
+like( $expected_request->content, qr/success/,
+       "Correct success message returned" );
+       
+# Now check that the tradition in question actually has a stemma!
+my $stemma_request = request('/stemmadot/' . $textids->{'public'} . '/0' );
+like( $stemma_request->header('Content-Type'), qr/application\/json/,
+       "Returned JSON answer for stemma request" );
+my $new_stemma = decode_json( $stemma_request->content );
+# It will be undirected.
+like( $new_stemma->{dot}, qr/^graph .RHM 1382777054_0/, 
+       "Found an undirected stemma DOT file where we expected" );
+
+# And check that the job ID was in fact removed.
+my $duplicate_request = request POST '/stemweb/result',
+       'Content-Type' => 'application/json',
+       'Content' => $answer;
+like( $duplicate_request->header('Content-Type'), qr/application\/json/,
+       "Returned JSON answer for duplicate request" );
+like( $duplicate_request->content, qr/No tradition found with Stemweb job ID/,
+       "Job ID was removed from relevant tradition" );
+
+done_testing();
+
+
+sub _make_testing_database {
+       my $fh = File::Temp->new();
+       my $file = $fh->filename;
+       $fh->unlink_on_destroy( 0 );
+       $fh->close;
+       my $dsn = 'dbi:SQLite:dbname=' . $file;
+       my $dir = Text::Tradition::Directory->new( 'dsn' => $dsn,
+               'extra_args' => { 'create' => 1 } );
+       my $scope = $dir->new_scope;
+       
+       my $textids = {};
+       # Create a (public) tradition
+       my $pubtrad = Text::Tradition->new( input => 'Self', file => 't/data/besoin.xml' );
+       $pubtrad->public( 1 );
+       $textids->{'public'} = $dir->store( $pubtrad );
+               
+       # Create a user
+       my $adminobj = $dir->add_user( { username => 'stadmin', password => 'stadminpass', role => 'admin' } );
+       my $userobj = $dir->add_user( { username => 'swtest', password => 'swtestpass' } );
+       # Create a tradition for the normal user
+       my $privtrad = Text::Tradition->new( input => 'Tabular', sep_char => ',',
+               file => 't/data/florilegium.csv' );
+       $userobj->add_tradition( $privtrad );
+       $privtrad->add_stemma( dotfile => 't/data/florilegium.dot' );
+       $textids->{'private'} = $dir->store( $privtrad );
+       $dir->store( $userobj );
+       
+       ## Now replace the existing traditions database with the test one
+       my( $orig, $was_link );
+       if( -l $dbfile ) {
+               $was_link = 1;
+               $orig = readlink( $dbfile );
+               unlink( $dbfile ) or die "Could not replace database file $dbfile";
+       } elsif( -e $dbfile ) {
+               my $suffix = '.backup.' . time();
+               $orig = $dbfile.$suffix;
+               rename( $dbfile, $orig ) or die "Could not move database file $dbfile";
+       }
+       symlink( $file, $dbfile );
+       return( $orig, $was_link, $textids );
+}
+
+END {
+       # Restore the original database
+       unlink( readlink( $dbfile ) );
+       unlink( $dbfile );
+       if( $was_link ) {
+               symlink( $orig_db, $dbfile );
+       } elsif( $orig_db ) {
+               rename( $orig_db, $dbfile );
+       }
+}
diff --git a/t/data/besoin_stemweb.dot b/t/data/besoin_stemweb.dot
new file mode 100644 (file)
index 0000000..75df083
--- /dev/null
@@ -0,0 +1,52 @@
+graph stemma {
+  0 [ class=hypothetical ];
+  1 [ class=hypothetical ];
+  10 [ class=hypothetical ];
+  11 [ class=hypothetical ];
+  2 [ class=hypothetical ];
+  3 [ class=hypothetical ];
+  4 [ class=hypothetical ];
+  5 [ class=hypothetical ];
+  6 [ class=hypothetical ];
+  7 [ class=hypothetical ];
+  8 [ class=hypothetical ];
+  9 [ class=hypothetical ];
+  A [ class=extant ];
+  B [ class=extant ];
+  C [ class=extant ];
+  D [ class=extant ];
+  F [ class=extant ];
+  J [ class=extant ];
+  L [ class=extant ];
+  M [ class=extant ];
+  S [ class=extant ];
+  T1 [ class=extant ];
+  T2 [ class=extant ];
+  U [ class=extant ];
+  V [ class=extant ];
+  0 -- 1;
+  0 -- C;
+  10 -- 11;
+  10 -- 9;
+  10 -- V;
+  11 -- F;
+  11 -- U;
+  1 -- 2;
+  1 -- M;
+  2 -- 3;
+  2 -- D;
+  3 -- 4;
+  4 -- 5;
+  5 -- 6;
+  6 -- 7;
+  7 -- 8;
+  8 -- 9;
+  A -- 6;
+  B -- 4;
+  J -- 5;
+  L -- 3;
+  S -- 9;
+  T1 -- 8;
+  T2 -- 7;
+}
+