Integrated search for a static website Part 2
Having decided to create my own AJAX driven search function for this static blog, here's how I implemented it. There were essentially three steps.
- Create and populate the ColdFusion/Solr collection.
- Create a remote CFC method as part of my blog generation app to search the collection and return results via AJAX.
- Write javascript/jQuery to call the remote CFC and display the results on a static HTML results page.
(Note: All the code below assumes CF version 9.0.1)
1. Create and populate the ColdFusion/Solr collection
For brevity's sake I'm assuming you're comfortable setting up and working with ColdFusion search collections. If not, check out the recent presentation by Raymond Camden or start with this nice basic introduction.
Also, I tend to use abstracted "wrapper" objects to create and search my Solr collections so you won't see any of the search related CF tags directly in the code below. The focus here is on integration with a static website rather than CF/Solr itself.
2. Create a remote CFC method to search and return results as JSON
Like many developers I prefer to expose my CFC methods to Ajax calls via a "remote proxy". This is a CFC that sits below the webroot and has remote methods which can be called using public URLs, but which then "talks" to other CFCs that are protected by being placed above the webroot.
remote.cfc
<cfcomponent output="false">
<cfscript>
// helper function
private function abortWithResponseHeader( required numeric statusCode )
hint="Call me to abort the current request and send a specific HTTP response header"
{
getPageContext().getResponse().setStatus( arguments.statusCode );
abort;
}
// helper function
private function checkSearchArguments( q )
hint="I perform a basic check to make sure the request is providing the right parameters"
{
if( IsNull( arguments.q ) OR !IsSimpleValue( arguments.q ) )
abortWithResponseHeader( 400 ); //bad request
}
remote struct function search( q ) returnFormat="json"
hint="I'm the AJAX search method"
{
checkSearchArguments( arguments.q );
try
{
// using CF/Solr search abstracted into a custom object in a mapped location above the webroot
// pass it the search query
var searchObject = New my.cfcs.search( criteria=arguments.q );
// create a new struct which will contain the criteria and results
var search = {};
// get the safely cleaned criteria
search[ "query" ] = searchObject.sanitisedCriteria();
// initialise the results as an array which will be converted to JSON
search[ "results" ] = [];
// transfer the results query to the array: arrays are easier to work with when dealing with JSON
for( var i=1; i LTE searchObject.results().recordcount; i++ )
{
// each cell in the array will contain a struct containing the query column name/values
search.results[ i ] = {};
search.results[ i ][ "key" ] = searchObject.results().key[ i ];
search.results[ i ][ "title" ] = HtmlEditFormat( searchObject.results().title[ i ] );
search.results[ i ][ "context" ] = searchObject.results().context[ i ];
}
// the ColdFusion struct will be automatically returned as JSON
return search;
}
catch( any exception )
{
abortWithResponseHeader( 500 ); // Internal server error
}
}
</cfscript>
</cfcomponent>
The first 2 methods are private: they can only be called by other methods in the CFC and are there just as helpers to the main remote search function.
The main method would be called by a jQuery ajax function such as
jQuery.getJSON( "/remote.cfc?method=search&q='test'",function( data ){ /*do sth with the data*/} );
The method would take the search input ("test") and pass it to my custom search object. Because you should never trust incoming URL parameters or form data, my search object performs various safety checks before talking to CF/Solr behind the scenes and making the results available as a CF query.
The sanitised input criteria and results recordset are then massaged into CF struct and array form which are more easily consumed as JSON.
Cross domain problem
I say "would" because it's not what I ended up with. It would work if my blog generation app and the blog itself were on the same domain. But the app is running on a different domain which means I can't make ajax calls for JSON directly from the static blog to the app for security reasons.
JSONP
One way to solve this problem is to return JSONP rather than just JSON. The P stands for "padding" or "prefix" which, if you're unfamiliar with the concept and anything like me, probably won't enlighten you a great deal. JSON-IF might have been a more descriptive (but less accurate) acronym, since what you typically do is wrap the JSON data Inside a Function before returning it. So instead of...
remote struct function search( q ) returnFormat="json"
{
...
return search;
}
...where the returnFormat tells CF to convert the "search" struct to JSON, to return JSONP you use SerializeJSON() to convert the CF struct and then return it as a string wrapped inside a javascript function:
remote struct function search( q ) returnFormat="plain"
{
...
return "myFunction( #SerializeJSON( search )# );";
}
Rather than just data you are now returning a piece of executable Javascript, which allows it to be included via a <script> tag which can point to an external domain.
This might seem a bit complicated, but fortunately jQuery's ajax functionality will handle JSONP for you on the client side as long as your CFC method returns the JSON data wrapped ("padded") in a function.
One further complication however (which puzzled me for quite a while) is that the name of the padding function should be specified by the calling page, not hardcoded into the CFC method. So instead of "myFunction", we need to allow the callback function's name to be provided as an argument to the method:
remote struct function search( q,callback ) returnFormat="plain"
{
...
return "#arguments.callback#( #SerializeJSON( search )# );";
}
Below is our remote.cfc modified to return JSONP. The checkSearchArguments() helper method covers the new "callback" parameter as well as the search query, and the main search method now returns a JS function as plain text with the JSON data wrapped inside it.
<cfcomponent output="false">
<cfscript>
private function abortWithResponseHeader( required numeric statusCode )
{
getPageContext().getResponse().setStatus( arguments.statusCode );
abort;
}
private function checkSearchArguments( q,callback )
{
if( IsNull( arguments.q ) OR IsNull( arguments.callback ) OR !IsSimpleValue( arguments.q ) OR !IsSimpleValue( arguments.callback ) )
abortWithResponseHeader( 400 );
}
remote function search( q,callback ) returnFormat="plain"
{
checkSearchArguments( arguments.q,arguments.callback );
try
{
var searchObject = New my.cfcs.search( criteria=arguments.q );
var search = {};
search[ "query" ] = searchObject.sanitisedCriteria();
search[ "results" ] = [];
for( var i=1; i LTE searchObject.results().recordcount; i++ )
{
search.results[ i ] = {};
search.results[ i ][ "key" ] = searchObject.results().key[ i ];
search.results[ i ][ "title" ] = HtmlEditFormat( searchObject.results().title[ i ] );
search.results[ i ][ "context" ] = searchObject.results().context[ i ];
}
//This is JSONP: "padding" the JSON data with a callback function to allow cross-domain calls
return "#arguments.callback#( #SerializeJson( search )# );";
}
catch( any exception )
{
abortWithResponseHeader( 500 );
}
}
</cfscript>
</cfcomponent>
3. Write javascript/jQuery to call the remote CFC and display the results in the HTML page
Now all we need to do is throw some jQuery at the static blog's search page to retrieve and process the JSONP. The html and script are separated for the sake of clearer syntax colouring.
search.html
<html>
<head>
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.6.2/jquery.min.js"></script>
</head>
<body>
<div id="content">
<h1>Search my blog</h1>
<noscript>Please enable Javascript to use the search function</noscript>
<script><!-- Script goes here--></script>
</div>
</body>
</html>
search.html (script)
// URL of the remote method, e.g. http://mycfserver/remote.cfc?method=search
var remoteSearchMethodUrl = "";
// build the HTML elements we will need
var contentFrame = $( "#content" );
var searchForm = $( '<form id="search-form" action=""/>' ).appendTo( contentFrame );
var searchInput = $( '<input type="search" required id="search-input" name="q"/>' ).appendTo( searchForm );
var goButton = $( '<button type="submit">Go</button>' ).appendTo( searchForm );
var resultsFrame = $( '<div id="resultsFrame"/>' ).appendTo( contentFrame );
var resultsHeader = $( '<h2></h2>' ).appendTo( resultsFrame );
var resultsList = $( '<dl id="results"/>' ).appendTo( resultsFrame );
var message = $( '<p/>' );
// the search function
var doSearch = function( q ){
// reset the error message and results section
message.remove().empty();
resultsList.empty();
resultsHeader.empty();
jQuery.ajax(
{
url:remoteSearchMethodUrl + "&q=" + q
,dataType:"jsonp"
,cache:true
// loading message
,beforeSend:function(){ message.text( "Just a moment..." ).addClass( "loading" ).prependTo( resultsFrame ); }
// timeout 10 secs: essential for error event to fire with JSONP
,timeout:10000
,error:function(){ message.removeClass( "loading" ).text( "Sorry the search isn't working at the moment" ); }
,success:function( data ){
message.removeClass( "loading" ).remove();
// add each result row to the list
$.each( data.results,function( index,value ){
resultsList.append('<dt><a href="/' + value.key + '/">' + value.title + '</a></dt><dd>...' + value.context + '...</dd>');
});
// Make sure our English is correct
var resultString = ( ( data.results.length != 1 ) ? " results" : " result" );
resultsHeader.text( data.results.length + resultString + " found" );
// echo the search criteria with the *sanitised* version returned from the server, NOT the original entered by the client
searchInput.val( data.query );
}
}
);
return false;
};
// Detect a search query in the URL
var getUrlQuery = function(){
var results = new RegExp( '[\\?&]q=([^]+)' ).exec( window.location.href );
return results ? $.trim( results[ 1 ] ) : false;
};
var q = getUrlQuery();
// if we've got a search query, run the search
if( q )
doSearch( q );
Comments explain the sections but basically we are:
- building the required HTML elements
- creating the function to process the search, including error handling
- detecting if a search query is present in the page's URL and if so...
- executing the search
JSONP handling has been improved in recent versions of jQuery so it's important to be using the latest, or at least 1.5.
A couple of other small "gotchas" in the jQuery.ajax() call to note are:
- The timeout value is essential for the error handler function to work with JSONP.
- Pass the search query parameter in the URL of the ajax call, not as data. Otherwise undesired space encoding seems to occur with multiple terms.
Try it out
Make sure Javascript is enabled in your browser and then go ahead and type something into the search box at the top of the page, or follow this sample search link.
And finally...
I would really welcome corrections and suggestions for improvement. JSONP is new to me and although necessary in my situation, certainly seems to add complexity. If you can see any scope for making it simpler please do let me know.
Comments