[Koha-patches] [PATCH] New framework for AJAX services

Jesse Weaver pianohacker at gmail.com
Mon Apr 6 00:40:13 CEST 2009


From: Pianohacker <pianohacker at gmail.com>

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.
---
 C4/Output.pm            |   95 ++++++++++------
 C4/Output/JSONStream.pm |   74 ++++++++++++
 C4/Service.pm           |  291 +++++++++++++++++++++++++++++++++++++++++++++++
 opac/opac-search.pl     |   16 ++-
 opac/opac-tags.pl       |    8 +-
 tags/review.pl          |    6 +-
 6 files changed, 441 insertions(+), 49 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 fd32541..8ab13fe 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' )
@@ -347,48 +347,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 0000000..fb32c4f
--- /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 0000000..641ab2f
--- /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<CGI> object and a C<C4::Output::JSONStream>. The latter can
+be used for both flat scripts and those that use dispatch(), and should be
+passed to C<return_success()>.
+
+=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<C4::Output::JSONStream> 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
+C<C4::Service->require_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<dispatch> 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 <jesse.weaver at liblime.com>
diff --git a/opac/opac-search.pl b/opac/opac-search.pl
index f9d51f9..816ad06 100755
--- a/opac/opac-search.pl
+++ b/opac/opac-search.pl
@@ -604,10 +604,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 = $cgi->param('format') =~ /rss|atom/ ? "application/xml" :
-                   "text/html";
 
 # Build drop-down list for 'Add To:' menu...
 my $session = get_session($cgi->cookie("CGISESSID"));
@@ -629,4 +625,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 2b9dfd7..72688b7 100755
--- a/opac/opac-tags.pl
+++ b/opac/opac-tags.pl
@@ -46,7 +46,7 @@ my %counts  = ();
 my @errors  = ();
 
 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');
@@ -54,9 +54,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),
@@ -168,7 +168,7 @@ if ($is_ajax) {
 		}
 		$err_string .= "\n\t]\n";	# close response_function
 	}
-	output_ajax_with_http_headers($query, "$js_reply\n$err_string};");
+	output_with_http_headers($query, undef, "$js_reply\n$err_string};", 'js');
 	exit;
 }
 
diff --git a/tags/review.pl b/tags/review.pl
index 8bdfaa9..421da4e 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;
 }
 
-- 
1.6.2




More information about the Koha-patches mailing list