From b04f432857617c6a7b3333863e34420103321ea1 Mon Sep 17 00:00:00 2001 From: Pianohacker Date: Sun, 5 Apr 2009 17:40:13 -0500 Subject: [PATCH] New framework for AJAX services This adds two new C4 modules, C4::Service and ::Output::JSONStream, and makes important modifications to C4::Output. The first two are a basic framework for JSON-based AJAX services and a simple JSON output wrapper, respectively. C4::Output has been slightly refactored, with a new function, output_with_http_headers, that supports different content-types. output_html_with_http_headers still exists, and the three pages affected by this change have been refactored to support it. Signed-off-by: Galen Charlton --- C4/Output.pm | 95 ++++++++----- C4/Output/JSONStream.pm | 74 ++++++++++ C4/Service.pm | 291 ++++++++++++++++++++++++++++++++++++++++ opac/opac-search.pl | 21 +-- opac/opac-tags.pl | 8 +- tags/review.pl | 6 +- 6 files changed, 441 insertions(+), 54 deletions(-) create mode 100644 C4/Output/JSONStream.pm create mode 100644 C4/Service.pm diff --git a/C4/Output.pm b/C4/Output.pm index 36b83a2904..f1cf80b064 100644 --- a/C4/Output.pm +++ b/C4/Output.pm @@ -38,17 +38,17 @@ BEGIN { $VERSION = 3.03; require Exporter; @ISA = qw(Exporter); - @EXPORT_OK = qw(&output_ajax_with_http_headers &is_ajax); # More stuff should go here instead + @EXPORT_OK = qw(&is_ajax ajax_fail); # More stuff should go here instead %EXPORT_TAGS = ( all =>[qw(&themelanguage &gettemplate setlanguagecookie pagination_bar - &output_ajax_with_http_headers &output_html_with_http_headers)], - ajax =>[qw(&output_ajax_with_http_headers is_ajax)], - html =>[qw(&output_html_with_http_headers)] + &output_with_http_headers &output_html_with_http_headers)], + ajax =>[qw(&output_with_http_headers is_ajax)], + html =>[qw(&output_with_http_headers &output_html_with_http_headers)] ); push @EXPORT, qw( &themelanguage &gettemplate setlanguagecookie pagination_bar ); push @EXPORT, qw( - &output_html_with_http_headers + &output_html_with_http_headers &output_with_http_headers ); } @@ -93,7 +93,7 @@ sub gettemplate { die_on_bad_params => 1, global_vars => 1, case_sensitive => 1, - loop_context_vars => 1, # enable: __first__, __last__, __inner__, __odd__, __counter__ + loop_context_vars => 1, # enable: __first__, __last__, __inner__, __odd__, __counter__ path => ["$htdocs/$theme/$lang/$path"] ); my $themelang=( $interface ne 'intranet' ? '/opac-tmpl' : '/intranet-tmpl' ) @@ -351,48 +351,69 @@ sub pagination_bar { return $pagination_bar; } -=item output_html_with_http_headers +=item output_with_http_headers - &output_html_with_http_headers($query, $cookie, $html[, $content_type]) + &output_with_http_headers($query, $cookie, $data, $content_type[, $status]) -Outputs the HTML page $html with the appropriate HTTP headers, -with the authentication cookie $cookie and a Content-Type that -corresponds to the HTML page $html. +Outputs $data with the appropriate HTTP headers, +the authentication cookie $cookie and a Content-Type specified in +$content_type. -If the optional C<$content_type> parameter is called, set the -response's Content-Type to that value instead of "text/html". +If applicable, $cookie can be undef, and it will not be sent. + +$content_type is one of the following: 'html', 'js', 'json', 'xml', 'rss', or 'atom'. + +$status is an HTTP status message, like '403 Authentication Required'. It defaults to '200 OK'. =cut -sub output_html_with_http_headers ($$$;$) { - my $query = shift; - my $cookie = shift; - my $html = shift; - my $content_type = @_ ? shift : "text/html"; - $content_type = "text/html" unless $content_type =~ m!/!; # very basic sanity check - print $query->header( - -type => $content_type, - -charset => 'UTF-8', - -cookie => $cookie, - -Pragma => 'no-cache', - -'Cache-Control' => 'no-cache', - ), $html; +sub output_with_http_headers($$$$;$) { + my ( $query, $cookie, $data, $content_type, $status ) = @_; + $status ||= '200 OK'; + + my %content_type_map = ( + 'html' => 'text/html', + 'js' => 'text/javascript', + 'json' => 'application/json', + 'xml' => 'text/xml', + # NOTE: not using application/atom+xml or application/rss+xml because of + # Internet Explorer 6; see bug 2078. + 'rss' => 'text/xml', + 'atom' => 'text/xml' + ); + + die "Unknown content type '$content_type'" if ( !defined( $content_type_map{$content_type} ) ); + + if ($cookie) { + print $query->header( + -type => $content_type_map{$content_type}, + -status => $status, + -charset => 'UTF-8', + -cookie => $cookie, + -Pragma => 'no-cache', + -'Cache-Control' => 'no-cache', + ); + } else { + print $query->header( + -type => $content_type_map{$content_type}, + -status => $status, + -charset => 'UTF-8', + -Pragma => 'no-cache', + -'Cache-Control' => 'no-cache', + ); + } + + print $data; } -sub output_ajax_with_http_headers ($$) { - my ($query, $js) = @_; - print $query->header( - -type => 'text/javascript', - -charset => 'UTF-8', - -Pragma => 'no-cache', - -'Cache-Control' => 'no-cache', - -expires =>'-1d', - ), $js; +sub output_html_with_http_headers ($$$) { + my ( $query, $cookie, $data ) = @_; + output_with_http_headers( $query, $cookie, $data, 'html' ); } sub is_ajax () { - my $x_req = $ENV{HTTP_X_REQUESTED_WITH}; - return ($x_req and $x_req =~ /XMLHttpRequest/i) ? 1 : 0; + my $x_req = $ENV{HTTP_X_REQUESTED_WITH}; + return ( $x_req and $x_req =~ /XMLHttpRequest/i ) ? 1 : 0; } END { } # module clean-up code here (global destructor) diff --git a/C4/Output/JSONStream.pm b/C4/Output/JSONStream.pm new file mode 100644 index 0000000000..fb32c4f3d3 --- /dev/null +++ b/C4/Output/JSONStream.pm @@ -0,0 +1,74 @@ +package C4::Output::JSONStream; +# +# Copyright 2008 LibLime +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place, +# Suite 330, Boston, MA 02111-1307 USA + +=head1 NAME + +C4::Output::JSONStream - progressively build JSON data + +=head1 SYNOPSIS + +my $json = new C4::Output::JSONStream; + +$json->param( issues => [ 'yes!', 'please', 'no', { emphasis = 'NO' } ] ); +$json->param( stuff => 'realia' ); + +print $json->output; + +=head1 DESCRIPTION + +This module allows you to build JSON incrementally. + +=cut + +use strict; +use warnings; + +use JSON; + +sub new { + my $class = shift; + my $self = { + data => {}, + options => {} + }; + + bless $self, $class; + + return $self; +} + +sub param { + my $self = shift; + + if ( @_ % 2 != 0 ) { + die 'param() received odd number of arguments (should be called with param => "value" pairs)'; + } + + for ( my $i = 0; $i < $#_; $i += 2 ) { + $self->{data}->{$_[$i]} = $_[$i + 1]; + } +} + +sub output { + my $self = shift; + + return to_json( $self->{data} ); +} + +1; diff --git a/C4/Service.pm b/C4/Service.pm new file mode 100644 index 0000000000..641ab2f3e8 --- /dev/null +++ b/C4/Service.pm @@ -0,0 +1,291 @@ +package C4::Service; +# +# Copyright 2008 LibLime +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation; either version 2 of the License, or (at your option) any later +# version. +# +# Koha is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along with +# Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place, +# Suite 330, Boston, MA 02111-1307 USA + +=head1 NAME + +C4::Service - functions for JSON webservices. + +=head1 SYNOPSIS + +my ( $query, $response) = C4::Service->init( { circulate => 1 } ); +my ( $borrowernumber) = C4::Service->require_params( 'borrowernumber' ); + +C4::Service->return_error( 'internal', 'Frobnication failed', frobnicator => 'foo' ); + +$response->param( frobnicated => 'You' ); + +C4::Service->return_success( $response ); + +=head1 DESCRIPTION + +This module packages several useful functions for JSON webservices. + +=cut + +use strict; +use warnings; + +use CGI; +use C4::Auth qw( check_api_auth ); +use C4::Output qw( :ajax ); +use C4::Output::JSONStream; +use JSON; + +our $debug; + +BEGIN { + $debug = $ENV{DEBUG} || 0; +} + +our ( $query, $cookie ); + +=head1 METHODS + +=head2 init + +=over 4 + + our ( $query, $response ) = C4::Service->init( %needed_flags ); + +=back + +Initialize the service and check for the permissions in C<%needed_flags>. + +Also, check that the user is authorized and has a current session, and return an +'auth' error if not. + +init() returns a C object and a C. The latter can +be used for both flat scripts and those that use dispatch(), and should be +passed to C. + +=cut + +sub init { + my ( $class, %needed_flags ) = @_; + + our $query = new CGI; + + my ( $status, $cookie_, $sessionID ) = check_api_auth( $query, \%needed_flags ); + + our $cookie = $cookie_; # I have no desire to offend the Perl scoping gods + + $class->return_error( type => 'auth', message => $status ) if ( $status ne 'ok' ); + + return ( $query, new C4::Output::JSONStream ); +} + +=head2 return_error + +=over 4 + + C4::Service->return_error( $type, $error, %flags ); + +=back + +Exit the script with HTTP status 400, and return a JSON error object. + +C<$type> should be a short, lower case code for the generic type of error (such +as 'auth' or 'input'). + +C<$error> should be a more specific code giving information on the error. If +multiple errors of the same type occurred, they should be joined by '|'; i.e., +'expired|different_ip'. Information in C<$error> does not need to be +human-readable, as its formatting should be handled by the client. + +Any additional information to be given in the response should be passed as +param => value pairs. + +=cut + +sub return_error { + my ( $class, $type, $error, %flags ) = @_; + + my $response = new C4::Output::JSONStream; + + $response->param( message => $error ) if ( $error ); + $response->param( type => $type, %flags ); + + output_with_http_headers $query, $cookie, $response->output, 'json', '400 Bad Request'; + exit; +} + +=head return_multi + +=over 4 + +C4::Service->return_multi( \@responses, %flags ); + +=back + +return_multi is similar to return_success or return_error, but allows you to +return different statuses for several requests sent at once (using HTTP status +"207 Multi-Status", much like WebDAV). The toplevel hashref (turned into the +JSON response) looks something like this: + +=over 4 + +{ multi => JSON::true, responses => \@responses, %flags } + +=back + +Each element of @responses should be either a plain hashref or an arrayref. If +it is a hashref, it is sent to the browser as-is. If it is an arrayref, it is +assumed to be in the same form as the arguments to return_error, and is turned +into an error structure. + +All key-value pairs %flags are, as stated above, put into the returned JSON +structure verbatim. + +=cut + +sub return_multi { + my ( $class, $responses, @flags ) = @_; + + my $response = new C4::Output::JSONStream; + + if ( !@$responses ) { + $class->return_success( $response ); + } else { + my @responses_formatted; + + foreach my $response ( @$responses ) { + if ( ref( $response ) eq 'ARRAY' ) { + my ($type, $error, @error_flags) = @$response; + + push @responses_formatted, { is_error => JSON::true, type => $type, message => $error, @error_flags }; + } else { + push @responses_formatted, $response; + } + } + + $response->param( 'multi' => JSON::true, responses => \@responses_formatted, @flags ); + output_with_http_headers $query, $cookie, $response->output, 'json', '207 Multi-Status'; + } + + exit; +} + +=head2 return_success + +=over 4 + + C4::Service->return_success( $response ); + +=back + +Print out the information in the C C<$response>, then +exit with HTTP status 200. + +=cut + +sub return_success { + my ( $class, $response ) = @_; + + output_with_http_headers $query, $cookie, $response->output, 'json'; +} + +=head2 require_params + +=over 4 + + my @values = C4::Service->require_params( @params ); + +=back + +Check that each of of the parameters specified in @params was sent in the +request, then return their values in that order. + +If a required parameter is not found, send a 'param' error to the browser. + +=cut + +sub require_params { + my ( $class, @params ) = @_; + + my @values; + + for my $param ( @params ) { + $class->return_error( 'params', "Missing '$param'" ) if ( !defined( $query->param( $param ) ) ); + push @values, $query->param( $param ); + } + + return @values; +} + +=head dispatch + +=over 4 + +C4::Service->dispatch( + [ $path_regex, \@required_params, \&handler ], + ... +); + +=back + +dispatch takes several array-refs, each one describing a 'route', to use the +Rails terminology. + +$path_regex should be a string in regex-form, describing which paths this route +handles. Each route is tested in order, from the top down, so put more specific +handlers first. Also, the regex is tested on the entire path. + +Each named parameter in @required_params is tested for to make sure the route +matches, but does not raise an error if one is missing; it simply tests the next +route. If you would prefer to raise an error, instead use +Crequire_params> inside your handler. + +\&handler is called with each matched group in $path_regex in its arguments. For +example, if your service is accessed at the path /blah/123, and you call +C with the route [ '/blah/(\\d+)', ... ], your handler will be called +with the argument '123'. + +=cut + +sub dispatch { + my $class = shift; + + my $path_info = $query->path_info || '/'; + + ROUTE: foreach my $route ( @_ ) { + my ( $path, $params, $handler ) = @$route; + + next unless ( my @match = ( ($query->request_method . ' ' . $path_info) =~ m,^$path$, ) ); + + for my $param ( @$params ) { + next ROUTE if ( !defined( $query->param ( $param ) ) ); + } + + $debug and warn "Using $path"; + $handler->( @match ); + return; + } + + $class->return_error( 'no_handler', '' ); +} + +1; + +__END__ + +=head1 AUTHORS + +Koha Development Team + +Jesse Weaver diff --git a/opac/opac-search.pl b/opac/opac-search.pl index a928ce0517..03ca39ae86 100755 --- a/opac/opac-search.pl +++ b/opac/opac-search.pl @@ -610,15 +610,6 @@ if ( C4::Context->preference("kohaspsuggest") ) { } # VI. BUILD THE TEMPLATE -# NOTE: not using application/atom+xml or application/rss+xml beccause of Internet Explorer 6; -# see bug 2078. -my $content_type; -if ($cgi->param('format') && $cgi->param('format') =~ /rss|atom/ ){ - $content_type = "application/xml"; -} -else { - $content_type = "text/html"; -} # Build drop-down list for 'Add To:' menu... my $session = get_session($cgi->cookie("CGISESSID")); my @addpubshelves; @@ -639,4 +630,14 @@ if (defined $barshelves) { $template->param( addbarshelvesloop => $barshelves); } -output_html_with_http_headers $cgi, $cookie, $template->output, $content_type; +my $content_type; + +if ($cgi->param('format') =~ /rss/) { + $content_type = 'rss' +} elsif ($cgi->param('format') =~ /atom/) { + $content_type = 'atom' +} else { + $content_type = 'html' +} + +output_with_http_headers $cgi, $cookie, $template->output, $content_type; diff --git a/opac/opac-tags.pl b/opac/opac-tags.pl index 76c3abcf07..296fa07341 100755 --- a/opac/opac-tags.pl +++ b/opac/opac-tags.pl @@ -50,7 +50,7 @@ my $perBibResults = {}; my @globalErrorIndexes = (); sub ajax_auth_cgi ($) { # returns CGI object - my $needed_flags = shift; + my $needed_flags = shift; my %cookies = fetch CGI::Cookie; my $input = CGI->new; my $sessid = $cookies{'CGISESSID'}->value || $input->param('CGISESSID'); @@ -58,9 +58,9 @@ sub ajax_auth_cgi ($) { # returns CGI object $debug and print STDERR "($auth_status, $auth_sessid) = check_cookie_auth($sessid," . Dumper($needed_flags) . ")\n"; if ($auth_status ne "ok") { - output_ajax_with_http_headers $input, + output_with_http_headers $input, undef, "window.alert('Your CGI session cookie ($sessid) is not current. " . - "Please refresh the page and try again.');\n"; + "Please refresh the page and try again.');\n", 'js'; exit 0; } $debug and print STDERR "AJAX request: " . Dumper($input), @@ -212,7 +212,7 @@ if ($is_ajax) { $js_perbib .= $js_bibres; } - output_ajax_with_http_headers($query, "$js_reply\n$err_string\n$js_perbib\n};"); + output_with_http_headers($query, undef, "$js_reply\n$err_string\n$js_perbib\n};", 'js'); exit; } diff --git a/tags/review.pl b/tags/review.pl index 8bdfaa9861..421da4e64c 100755 --- a/tags/review.pl +++ b/tags/review.pl @@ -46,9 +46,9 @@ sub ajax_auth_cgi ($) { # returns CGI object $debug and print STDERR "($auth_status, $auth_sessid) = check_cookie_auth($sessid," . Dumper($needed_flags) . ")\n"; if ($auth_status ne "ok") { - output_ajax_with_http_headers $input, + output_with_http_headers $input, undef, "window.alert('Your CGI session cookie ($sessid) is not current. " . - "Please refresh the page and try again.');\n"; + "Please refresh the page and try again.');\n", 'js'; exit 0; } $debug and print STDERR "AJAX request: " . Dumper($input), @@ -72,7 +72,7 @@ if (is_ajax()) { if ($tag = $input->param('rej')) { $js_reply = ( blacklist($operator,$tag) ? 'success' : 'failure') . "_reject('$tag');\n"; } - output_ajax_with_http_headers $input, $js_reply; + output_with_http_headers $input, undef, $js_reply, 'js'; exit; } -- 2.39.5