send ASCII-only requests to Stemweb. Issue #46
[scpubgit/stemmaweb.git] / lib / stemmaweb / Controller / Stemweb.pm
CommitLineData
532cc23b 1package stemmaweb::Controller::Stemweb;
2use Moose;
3use namespace::autoclean;
ed0ce314 4use Encode qw/ decode_utf8 /;
3cb9d9c0 5use File::Which;
c2b80bba 6use JSON;
3cb9d9c0 7use List::Util qw/ max /;
70744367 8use LWP::UserAgent;
532cc23b 9use Safe::Isa;
c55cf210 10use Scalar::Util qw/ looks_like_number /;
3cb9d9c0 11use Text::Tradition::StemmaUtil qw/ character_input phylip_pars /;
532cc23b 12use TryCatch;
70744367 13use URI;
532cc23b 14
15BEGIN { extends 'Catalyst::Controller' }
16
d83d890b 17has stemweb_url => (
18 is => 'ro',
19 isa => 'Str',
20 default => 'http://slinkola.users.cs.helsinki.fi',
21 );
3cb9d9c0 22
23has pars_path => (
24 is => 'ro',
25 isa => 'Str',
26 );
27
28has pars_pk => (
29 is => 'rw',
30 isa => 'Int',
31 );
70744367 32
532cc23b 33=head1 NAME
34
35stemmaweb::Controller::Stemweb - Client listener for Stemweb results
36
37=head1 DESCRIPTION
38
39This is a client listener for the Stemweb API as implemented by the protocol defined at
40L<https://docs.google.com/document/d/1aNYGAo1v1WPDZi6LXZ30FJSMJwF8RQPYbOkKqHdCZEc/pub>.
41
42=head1 METHODS
43
44=head2 result
45
46 POST stemweb/result
47 Content-Type: application/json
48 (On success):
67b2a665 49 { jobid: <ID number>
532cc23b 50 status: 0
51 format: <format>
52 result: <data> }
53 (On failure):
54 { jobid: <ID number>
55 status: >1
56 result: <error message> }
57
58Used by the Stemweb server to notify us that one or more stemma graphs
59has been calculated in response to an earlier request.
60
61=cut
62
63sub result :Local :Args(0) {
64 my( $self, $c ) = @_;
65 if( $c->request->method eq 'POST' ) {
66 # TODO: Verify the sender!
67 my $answer;
68 if( ref( $c->request->body ) eq 'File::Temp' ) {
69 # Read in the file and parse that.
67b2a665 70 $c->log->debug( "Request body is in a temp file" );
71 open( POSTDATA, $c->request->body )
c0292f64 72 or return _json_error( $c, 500, "Failed to open post data file" );
532cc23b 73 binmode( POSTDATA, ':utf8' );
74 # JSON should be all one line
75 my $pdata = <POSTDATA>;
76 chomp $pdata;
77 close POSTDATA;
c2b80bba 78 try {
79 $answer = from_json( $pdata );
80 } catch {
81 return _json_error( $c, 400,
82 "Could not parse POST request '' $pdata '' as JSON: $@" );
83 }
532cc23b 84 } else {
85 $answer = from_json( $c->request->body );
86 }
67b2a665 87 $c->log->debug( "Received push notification from Stemweb: "
88 . to_json( $answer ) );
c2b80bba 89 return _process_stemweb_result( $c, $answer );
90 } else {
91 return _json_error( $c, 403, 'Please use POST!' );
92 }
93}
94
66458003 95=head2 available
96
97 GET algorithms/available
98
99Queries the Stemweb server for available stemma generation algorithms and their
100parameters. Returns the JSON answer as obtained from Stemweb.
101
102=cut
103
104sub available :Local :Args(0) {
105 my( $self, $c ) = @_;
106 my $ua = LWP::UserAgent->new();
d83d890b 107 my $resp = $ua->get( $self->stemweb_url . '/algorithms/available' );
f6dbd419 108 my $parameters = [];
66458003 109 if( $resp->is_success ) {
f6dbd419 110 $parameters = decode_json( $resp->content );
111 } # otherwise we have no available algorithms.
112 ## Temporary HACK: run Pars too
113 if( $self->_has_pars ) {
114 # Use the highest passed primary key + 1
115 my $parspk = max( map { $_->{pk} }
116 grep { $_->{model} eq 'algorithms.algorithm' } @$parameters ) + 1;
117 # Add Pars as an algorithm
118 $self->pars_pk( $parspk );
119 push( @$parameters, {
120 pk => $parspk,
121 model => 'algorithms.algorithm',
122 fields => {
123 args => [],
124 name => 'Pars',
125 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.'
126 }
127 });
66458003 128 }
f6dbd419 129 $c->stash->{result} = $parameters;
66458003 130 $c->forward('View::JSON');
131}
132
c2b80bba 133=head2 query
134
135 GET stemweb/query/<jobid>
136
137A backup method to query the stemweb server to check a particular job status.
138Returns a result as in /stemweb/result above, but status can also be -1 to
139indicate that the job is still running.
140
141=cut
142
143sub query :Local :Args(1) {
144 my( $self, $c, $jobid ) = @_;
145 my $ua = LWP::UserAgent->new();
d83d890b 146 my $resp = $ua->get( $self->stemweb_url . "/algorithms/jobstatus/$jobid" );
c2b80bba 147 if( $resp->is_success ) {
148 # Process it
149 my $response = decode_utf8( $resp->content );
150 $c->log->debug( "Got a response from the server: $response" );
151 my $answer;
152 try {
153 $answer = from_json( $response );
154 } catch {
532cc23b 155 return _json_error( $c, 500,
c2b80bba 156 "Could not parse stemweb response '' $response '' as JSON: $@" );
157 }
158 return _process_stemweb_result( $c, $answer );
159 } elsif( $resp->code == 500 && $resp->header('Client-Warning')
160 && $resp->header('Client-Warning') eq 'Internal response' ) {
161 # The server was unavailable.
162 return _json_error( $c, 503, "The Stemweb server is currently unreachable." );
163 } else {
164 return _json_error( $c, 500, "Stemweb error: " . $resp->code . " / "
165 . $resp->content );
166 }
167}
168
169
170## Helper function for parsing Stemweb result data either by push or by pull
171sub _process_stemweb_result {
172 my( $c, $answer ) = @_;
c0292f64 173 # Find the specified tradition and check its job ID.
c2b80bba 174 my $m = $c->model('Directory');
c0292f64 175 my $tradition = $m->tradition( $answer->{textid} );
176 unless( $tradition ) {
177 return _json_error( $c, 400, "No tradition found with ID "
178 . $answer->{textid} );
179 }
180 if( $answer->{status} == 0 ) {
181 my $stemmata;
182 if( $tradition->has_stemweb_jobid
183 && $tradition->stemweb_jobid eq $answer->{jobid} ) {
c2b80bba 184 try {
185 $stemmata = $tradition->record_stemweb_result( $answer );
186 $m->save( $tradition );
187 } catch( Text::Tradition::Error $e ) {
188 return _json_error( $c, 500, $e->message );
189 } catch {
190 return _json_error( $c, 500, $@ );
191 }
c0292f64 192 } else {
193 # It may be that we already received a callback meanwhile.
194 # Check all stemmata for the given jobid and return them.
195 @$stemmata = grep { $_->came_from_jobid && $_->from_jobid eq $answer->{jobid} } $tradition->stemmata;
196 }
c0292f64 197 if( @$stemmata ) {
c2b80bba 198 # If we got here, success!
c55cf210 199 # TODO Use helper in Root.pm to do this
c2b80bba 200 my @steminfo = map { {
201 name => $_->identifier,
202 directed => _json_bool( !$_->is_undirected ),
203 svg => $_->as_svg() } }
2c514a6f 204 @$stemmata;
c2b80bba 205 $c->stash->{'result'} = {
206 'status' => 'success',
207 'stemmata' => \@steminfo };
c2b80bba 208 } else {
c0292f64 209 # Hm, no stemmata found on this tradition with this jobid.
210 # Clear the tradition jobid so that the user can try again.
211 if( $tradition->has_stemweb_jobid ) {
212 $tradition->_clear_stemweb_jobid;
213 $m->save( $tradition );
214 }
215 $c->stash->{'result'} = { status => 'notfound' };
c2b80bba 216 }
69d4ecfe 217 } elsif( $answer->{status} == 1 ) {
c0292f64 218 $c->stash->{'result'} = { 'status' => 'running' };
c2b80bba 219 } else {
509e94bc 220 # Failure. Clear the job ID so that the user can try again.
221 $tradition->_clear_stemweb_jobid;
222 $m->save( $tradition );
223 $c->stash->{'result'} = { 'status' => 'failed', 'message' => $answer->{result} };
532cc23b 224 }
c2b80bba 225 $c->forward('View::JSON');
532cc23b 226}
227
70744367 228=head2 request
229
230 GET stemweb/request/?
231 tradition=<tradition ID> &
232 algorithm=<algorithm ID> &
233 [<algorithm parameters>]
234
235Send a request for the given tradition with the given parameters to Stemweb.
236Processes and returns the JSON response given by the Stemweb server.
237
238=cut
239
240sub request :Local :Args(0) {
241 my( $self, $c ) = @_;
242 # Look up the relevant tradition and check permissions.
243 my $reqparams = $c->req->params;
244 my $tid = delete $reqparams->{tradition};
245 my $t = $c->model('Directory')->tradition( $tid );
246 my $ok = _check_permission( $c, $t );
247 return unless $ok;
248 return( _json_error( $c, 403,
249 'You do not have permission to update stemmata for this tradition' ) )
250 unless $ok eq 'full';
251
70744367 252 my $algorithm = delete $reqparams->{algorithm};
3cb9d9c0 253 my $mergetypes = delete $reqparams->{merge_reltypes};
254 if( $self->_has_pars && $algorithm == $self->pars_pk ) {
255 my $start_time = scalar( gmtime( time() ) );
256 $t->set_stemweb_jobid( 'local' );
257 my $cdata = character_input( $t, { collapse => $mergetypes } );
258 my $newick;
70744367 259 try {
3cb9d9c0 260 $newick = phylip_pars( $cdata, { parspath => $self->_has_pars } );
261 } catch ( Text::Tradition::Error $e ) {
262 return _json_error( $c, 503, "Parsimony tree generation failed: "
263 . $e->message );
70744367 264 }
3cb9d9c0 265 # We have a result, so form an answer to process.
266 my $answer = {
267 status => 0,
268 algorithm => 'pars',
269 'format' => 'newick',
270 textid => $tid,
271 jobid => 'local',
272 result => $newick,
273 start_time => $start_time
274 };
275 return _process_stemweb_result( $c, $answer );
70744367 276 } else {
3cb9d9c0 277 # Form the request for Stemweb.
278 my $return_uri = URI->new( $c->uri_for( '/stemweb/result' ) );
0f254bdc 279 my $tsv_options = { noac => 1, ascii => 1 };
3cb9d9c0 280 if( $mergetypes && @$mergetypes ) {
281 $tsv_options->{mergetypes} = $mergetypes;
282 }
283 my $stemweb_request = {
284 return_path => $return_uri->path,
285 return_host => $return_uri->host_port,
286 data => $t->collation->as_tsv( $tsv_options ),
287 userid => $c->user->get_object->email,
288 textid => $tid,
289 parameters => _cast_nonstrings( $reqparams ) };
290
291 # Call to the appropriate URL with the request parameters.
292 my $ua = LWP::UserAgent->new();
b7c3508f 293 # $c->log->debug( 'Sending request to Stemweb: ' . to_json( $stemweb_request ) );
3cb9d9c0 294 my $resp = $ua->post( $self->stemweb_url . "/algorithms/process/$algorithm/",
295 'Content-Type' => 'application/json; charset=utf-8',
296 'Content' => encode_json( $stemweb_request ) );
297 if( $resp->is_success ) {
298 # Process it
299 $c->log->debug( 'Got a response from the server: '
300 . decode_utf8( $resp->content ) );
301 my $stemweb_response = decode_json( $resp->content );
302 try {
303 $t->set_stemweb_jobid( $stemweb_response->{jobid} );
304 } catch( Text::Tradition::Error $e ) {
305 return _json_error( $c, 429, $e->message );
306 }
307 $c->model('Directory')->save( $t );
308 $c->stash->{'result'} = $stemweb_response;
309 $c->forward('View::JSON');
310 } elsif( $resp->code == 500 && $resp->header('Client-Warning')
311 && $resp->header('Client-Warning') eq 'Internal response' ) {
312 # The server was unavailable.
313 return _json_error( $c, 503, "The Stemweb server is currently unreachable." );
314 } else {
315 return _json_error( $c, 500, "Stemweb error: " . $resp->code . " / "
316 . $resp->content );
317 }
70744367 318 }
319}
320
321# Helper to check what permission, if any, the active user has for
322# the given tradition
323sub _check_permission {
324 my( $c, $tradition ) = @_;
325 my $user = $c->user_exists ? $c->user->get_object : undef;
326 if( $user ) {
327 return 'full' if ( $user->is_admin ||
328 ( $tradition->has_user && $tradition->user->id eq $user->id ) );
329 }
330 # Text doesn't belong to us, so maybe it's public?
331 return 'readonly' if $tradition->public;
332
333 # ...nope. Forbidden!
334 return _json_error( $c, 403, 'You do not have permission to view this tradition.' );
335}
336
c55cf210 337# QUICK HACK to deal with strict Stemweb validation.
338sub _cast_nonstrings {
339 my $params = shift;
340 foreach my $k ( keys %$params ) {
341 my $v = $params->{$k};
342 if( looks_like_number( $v ) ) {
343 $params->{$k} = $v * 1;
344 } elsif ( !defined $v || $v eq 'true' ) {
345 $params->{$k} = _json_bool( $v );
346 }
347 }
348 return $params;
349}
350
532cc23b 351# Helper to throw a JSON exception
352sub _json_error {
353 my( $c, $code, $errmsg ) = @_;
354 $c->response->status( $code );
355 $c->stash->{'result'} = { 'error' => $errmsg };
356 $c->forward('View::JSON');
357 return 0;
c55cf210 358}
532cc23b 359
c2b80bba 360sub _json_bool {
361 return $_[0] ? JSON::true : JSON::false;
362}
363
3cb9d9c0 364sub _has_pars {
365 my $self = shift;
366 return $self->pars_path || which('pars');
367}
368
c2b80bba 369
ed0ce314 3701;