/*! Opensearchlight - v0.4.0 - 2013-08-16 * https://github.com/nsidc/OpenSearchlight * Copyright (c) 2013 Regents of the University of Colorado; Licensed MIT */ // ## About // // OpenSearchlight is a client library to access [OpenSearch](http://www.opensearch.org) services. // // // ## Usage // // `openSearchService` is the primary entry point to OpenSearchlight. Use it to // generate an `OpenSearchQuery` instance. The OSDD of the service is // retrieved, parsed, and passed into the `OpenSearchQuery` instance which is // in turn passed to a callback function. Handling and parsing of the OSDD // itself is handled by instances of `OpenSearchDescriptionDocument`. // // Use the query object (passed as the callback's parameter) to hit the search // service and get back results. // // Typical usage: // // OpenSearchlight.query({ // osdd: "http://www.example.com/opensearch?description", // contentType: "text/xml", // requestHeaders: [{name: "X-Requested-With", value: "MyApp"}] // parameters: { // searchTerms: "some search words", // startPage: "20", // resultsPerPage: "100" // }, // success: function (data) { // // data contains results! // }, // error: function (jqXHR, textStatus, errorThrown) { // // error handling... // } // }); // // The above is equivalent to the following code, which uses the objects in the // API explicitly: // // OpenSearchlight.openSearchService.query( // "http://www.example.com/opensearch?description", // function (query) { // query // .set("searchTerms", "some search words") // .set("startPage", "20") // .set("resultsPerPage", "100") // .setContentType("text/xml") // .setRequestHeaders([{name: "X-Requested-With", value: "MyApp"}]) // .execute({ // success: function (data) { // // data contains results! // }, // error: function (jqXHR, textStatus, errorThrown) { // // error handling... // } // }); // }); // ## OpenSearchlight // Declare the global object everything lives in var OpenSearchlight = OpenSearchlight || {}; (function () { // Top-level facade for all the rest of the API. // // * `params`: object literal, containing: // * `osdd`: URL of the OpenSearch service's OSDD // * `parameters`: search parameters to substitute into the search templates // * `success`: callback function to call with the results. The results will be passed as the first argument to the function. // * `requestHeaders`: optional array of HTTP request headers in the form of name,value pair objects. // * `contentType`: contentType requested of the service // * `error`: callback function if the opensearch query fails OpenSearchlight.query = function (params) { var queryFunction; OpenSearchlight.ensureParamsNotEmpty(params); OpenSearchlight.ensureParamsHasOsdd(params); OpenSearchlight.ensureParamsHasSuccessHandler(params); queryFunction = OpenSearchlight.generateOsddSuccessFn(params); OpenSearchlight.openSearchService.query(params.osdd, queryFunction, params.error); }; // Generator for the onSuccess method used when the OSDD is // successfully retrieved OpenSearchlight.generateOsddSuccessFn = function (params) { return function(query) { var queryParams; if (params instanceof Object) { // Extract query parameters _.each(params.parameters, function (val, key) { query.set(key, val); }); // Extract desired content type if (params.contentType !== undefined) { query.setContentType(params.contentType); } // Extract desired HTTP request headers if (params.requestHeaders !== undefined) { query.setRequestHeaders(params.requestHeaders); } } queryParams = OpenSearchlight.extendWith({}, params, ["success", "error", "queryXhr"]); query.execute(queryParams); }; }; // Copy selected properties from the `src` object to the `dest` object. // Returns a new object with the combined properties. // // * `dest`: object that new properties are added to // * `src`: object that properties are copied from // * `props`: a property or a list of properties to copy OpenSearchlight.extendWith = function(dest, src, props) { var result = _.extend(dest), propsToCopy = []; if (typeof src !== "object") { return dest; } propsToCopy = arrayify(props); _.each(propsToCopy, function(prop) { if (src.hasOwnProperty(prop)) { result[prop] = src[prop]; } }); return result; }; // ## Helper functions for OpenSearchlight // Converts scalar arguments into arrays with the scalar as the first array element. function arrayify(p) { var a = []; if (_.isArray(p)) { return p; } else { a.push(p); return a; } } OpenSearchlight.ensureParamsNotEmpty = function(params) { if (params === undefined) { throw new Error("Must pass a params object"); } }; OpenSearchlight.ensureParamsHasOsdd = function (params) { if (typeof params.osdd !== "string") { throw new Error("Must pass an osdd value in the params object"); } }; OpenSearchlight.ensureParamsHasSuccessHandler = function (params) { if (typeof params.success !== "function") { throw new Error("Must pass a success handler in the params object"); } }; }()); (function ($, _) { // ## OpenSearchQuery var OpenSearchQuery; // // **OpenSearchQuery** wraps up your query on an OpenSearch endpoint. // OpenSearchQuery = OpenSearchlight.OpenSearchQuery = function () { this.initialize.apply(this, arguments); }; // Instance methods _.extend(OpenSearchQuery.prototype, { // "constructor" - requires the xml of an OSDD or a fully formed OpenSearchDescriptionDocument initialize: function (osddXml) { if (arguments.length === 0) { throw new Error("Must pass OSDD's XML"); } if (osddXml instanceof OpenSearchlight.OpenSearchDescriptionDocument) { this.openSearchDescriptionDocument = osddXml; } else if (typeof osddXml === "string") { this.openSearchDescriptionDocument = this.createOpenSearchDescriptionDocument(osddXml); } else { throw new Error("Invalid argument to OpenSearchQuery"); } this.searchParams = {}; }, // Set a search parameter. Chainable method. set: function(key, val) { this.searchParams[key] = val; return this; }, // Set the desired content type to retrieve from the server. If the server // does not provide an endpoint that matches this value, you're liable to // get an error. Chainable method. setContentType: function (contentType) { this.contentType = contentType; return this; }, // Set optional request headers that can be used to track the ajax calls on the server side. // i.e. we can override X-Requested-With to "MyApp" etc. Chainable method. setRequestHeaders: function (requestHeaders) { this.requestHeaders = requestHeaders; return this; }, // Performs the current query. // // * `options`: object literal containing the following: // * `url`: URL to GET // * `success`: callback function for successful queries. Should accept one parameter: the results. // * `error` (optional): callback function for error conditions. Can take three // parameters: `jqXHR`, `textStatus`, and `errorThrown` (see // [http://api.jquery.com/jQuery.ajax/](http://api.jquery.com/jQuery.ajax/)) // * `queryXhr` (optional): callback function for the search query jqXHR. // Callback will be called with one parameter: the jqXHR. execute: function(options) { var queryUrl = this.openSearchDescriptionDocument.getQueryUrl(this.getParams(), this.getContentType()), headers = this.getRequestHeaders(); var xhr = $.ajax({ url: queryUrl, beforeSend: function(jqXhr) { _.each(headers, function (header) { jqXhr.setRequestHeader(header.name, header.value); }, this); }, success: function (data, textStatus, jqXhr) { options.success(jqXhr); }, error: options.error }); if (options.queryXhr !== undefined) { options.queryXhr(xhr); } }, get: function(key) { return this.searchParams[key]; }, getRequestHeaders: function () { return this.requestHeaders; }, getContentType: function () { return this.contentType; }, getParams: function () { return this.searchParams; }, createOpenSearchDescriptionDocument: function (osddXml) { return new OpenSearchlight.OpenSearchDescriptionDocument(osddXml); } }); // Class methods - none! _.extend(OpenSearchQuery, { }); }(jQuery, _)); // ## openSearchService (function ($, _) { // Factory to create an OpenSearchQuery OpenSearchlight.openSearchService = { // Retrieves the OSDD at the specified url (if necessary), and calls back to // onSuccess when complete, providing onSuccess a query object to work with. query: function (url, onSuccess, onError) { $.ajax({ url: url, success: _.bind(function (data, textStatus, jqXhr) { onSuccess.call(this, this.createQueryObject(jqXhr.responseText)); }, this), error: _.bind(function (errorXhr) { onError.call(this, errorXhr); }, this) }); }, // Creates an OpenSearchQuery object. Easy to override if you don't actually // want to incur the cost of that, e.g. for testing. createQueryObject: function(osddXml) { return new OpenSearchlight.OpenSearchQuery(osddXml); } }; }(jQuery, _)); // ## OpenSearchDescriptionDocument // // `OpenSearchDescriptionDocument` wraps up parsing the good stuff out of an // OSDD, and generates the query URL for someone else to GET later on. // Hide the internals within the closure scope. Pull in jQuery and Underscore // from the environment. (function ($, _) { // Shortcut name var OSDD; // Create a simple constructor function; behaviors will be added shortly. // Just like the backbone.js way. OSDD = OpenSearchlight.OpenSearchDescriptionDocument = function (osddXml) { this.initialize.apply(this, arguments); }; // *Instance methods* _.extend(OSDD.prototype, { // Override `initialize` to override the default constructor behavior. // // * `osddXml`: the XML content of an OSDD initialize: function (osddXml) { if (!OSDD.validate(osddXml)) { throw new Error("Error parsing xml"); } this.osddXml = osddXml; }, // Based on the OSDD template URLs, the search parameters and the desired // content type, construct the best fitting query URL. Content type has // the highest priority; search parameters are lower. Parameters without a // match in the template are ignored. // // * `params`: an object containing template parameters and their values to // substitute into the template url // * `contentType`: string with the desired content type, e.g. "text/xml" getQueryUrl: function (params, contentType) { var urlTemplates, template, queryUrl; urlTemplates = OSDD.extractTemplateUrls(this.osddXml); template = OSDD.getBestTemplate(urlTemplates, contentType, params); queryUrl = OSDD.substituteTemplateParameters(template, params); return queryUrl; } }); // *Class methods*. These are internal methods, but exposed for overriding as // necessary. _.extend(OSDD, { // Validate the OSDD XML. Returns `true` if XML is valid OSDD. // *Not currently very sophisticated...* // // * `osddXml`: string with the XML of the OSDD validate: function (osddXml) { if (!osddXml) { return false; } // TODO: replace this naive text matching with some actual XML parsing if (!osddXml.match(/http:\/\/a9.com\/-\/spec\/opensearch\/1.1\//)) { return false; } return true; }, // Returns an array of JSON objecs representing the template URLs found in // the OSDD XML, or undefined if none are found. // // * `osddXml`: string with the XML of the OSDD extractTemplateUrls: function (osddXml) { var templates = [], // TODO: would be nice to remove the dependency on jQuery here: jqTemplates = $(osddXml).find('url[rel!=self]'); jqTemplates.each(function (index, element) { templates.push( { type: this.getAttribute("type"), template: this.getAttribute("template") }); }); if (templates.length === 0) { return undefined; } else { return templates; } }, // Parameters: // // * `urlTemplates`: array of `{ type: "contentType", template: "urlTemplate" }` objects, as found in the OSDD // * `contentType`: requested content type, e.g. `"application/json"` // * `params`: object containing search parameters getBestTemplate: function (urlTemplates, contentType, params) { var filteredByType, filteredByRequiredParams, bestMatch; filteredByType = OSDD.filterUrlTemplatesOnMimeType(urlTemplates, contentType); filteredByRequiredParams = OSDD.filterUrlTemplatesWithMissingRequiredParams(filteredByType, params); bestMatch = OSDD.findTemplateWithMostParamMatches(filteredByRequiredParams, params); return bestMatch.template; }, filterUrlTemplatesOnMimeType: function (urlTemplates, type) { return _.filter(urlTemplates, function (urlTemplate) { return OSDD.doesContentTypeMatch(type, urlTemplate.type); }); }, // Perform some simple content type matching. Returns true if the type does // match the matcher, false otherwise. // // * `matcher`: desired content type. Wildcards, e.g. `"*/*"` or `"text/*"` are allowed. // * `type`: content type to match against doesContentTypeMatch: function (matcher, type) { var matcher_parts, type_parts; if (matcher === "*/*") { return true; } else if (matcher === type) { return true; } matcher_parts = matcher.split("/"); type_parts = type.split("/"); if (matcher_parts[0] === type_parts[0] && matcher_parts[1] === "*") { return true; } else if (matcher_parts[0] === "*" && matcher_parts[1] === type_parts[1]) { return true; } return false; }, // Return the subset of templates in the `urlTemplates` array whose // required params are available in the `params` object filterUrlTemplatesWithMissingRequiredParams: function (urlTemplates, params) { return _.filter(urlTemplates, function (urlTemplate) { return OSDD.areAllRequiredParamsPresent(urlTemplate, params); }); }, areAllRequiredParamsPresent: function (template, params) { var allParams, requiredParams, actualParams; allParams = _.map( template.template.match(/\{[^}]*\}/g), // Find all template substrings function (p) { return p.replace(/[{}]/g, ''); // Strip off the braces } ); // Select the subset of template parameters that don't end with a "?" requiredParams = _.filter( allParams, function(param) { return param.substring(param.length-1) !== "?"; } ); actualParams = _.keys(params); return ((_.difference(requiredParams, actualParams)).length === 0); }, XareAllRequiredParamsPresent: function (template, params) { var requiredParams, actualParams; requiredParams = _.map( template.template.match(/\{.*?[^?]\}/g), function (p) { return p.replace(/[{}]/g, ''); }); actualParams = _.keys(params); return ((_.difference(requiredParams, actualParams)).length === 0); }, findTemplateWithMostParamMatches: function (templateUrls, params) { var sortedTemplateUrls = _.sortBy( templateUrls, function (urlTemplate) { return OSDD.countMatchingParams(params, urlTemplate.template); }); return _.last(sortedTemplateUrls); }, countMatchingParams: function(params, templateUrl) { var templateParams; templateParams = _.map( templateUrl.match(/\{.*?\}/g), function (param) { return param.replace(/[{}]/g, ''); }); return _.reduce( templateParams, function(memo, item) { return (params[item] === undefined) ? memo : memo + 1; }, 0); }, // Replace the placeholder fields in the given `urlTemplate` with the // values in `params`. Any unfilled parameter template fields are replaced // with an empty string. substituteTemplateParameters: function (urlTemplate, params) { var url = urlTemplate; // interpolate all params into the url placeholders _.each(params, function(value, key) { url = url.replace(new RegExp("{"+key+"\\??}"), value); }); // strip out any remaining {....?} template params url = url.replace(/\{.*?\?\}/g, ""); return url; } }); }(jQuery, _));