From b5bc18c7eda7d06a1d03d3ab3765988791f52a33 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Mon, 5 Dec 2016 02:29:36 -0500 Subject: [PATCH] Add new init(options) signature for server endpoints An endpoint can now take a single object containing 'method', 'pathname', 'query', 'headers', and 'data' and return an integer, an array containing [statusCode, contentType, body], or a promise for either. This allows the handlers to use the HTTP method and headers and removes the need for callbacks when some handlers already use coroutine(). If init() returns a promise, it now has to use the new single-parameter signature (because the check is done with Function.length, and combining promises and callbacks doesn't make sense anyway). --- chrome/content/zotero/xpcom/server.js | 56 ++++++- .../content/zotero/xpcom/server_connector.js | 66 ++++---- test/tests/serverTest.js | 146 ++++++++++++++++++ 3 files changed, 229 insertions(+), 39 deletions(-) create mode 100644 test/tests/serverTest.js diff --git a/chrome/content/zotero/xpcom/server.js b/chrome/content/zotero/xpcom/server.js index 5cf8b52fd0..74067e883d 100755 --- a/chrome/content/zotero/xpcom/server.js +++ b/chrome/content/zotero/xpcom/server.js @@ -282,7 +282,7 @@ Zotero.Server.DataListener.prototype._headerFinished = function() { if(method[1] == "HEAD" || method[1] == "OPTIONS") { this._requestFinished(this._generateResponse(200)); } else if(method[1] == "GET") { - this._processEndpoint("GET", null); + this._processEndpoint("GET", null); // async } else if(method[1] == "POST") { const contentLengthRe = /[\r\n]Content-Length: +([0-9]+)/i; @@ -322,7 +322,7 @@ Zotero.Server.DataListener.prototype._bodyData = function() { } // handle envelope - this._processEndpoint("POST", this.body); + this._processEndpoint("POST", this.body); // async } } @@ -358,7 +358,7 @@ Zotero.Server.DataListener.prototype._generateResponse = function(status, conten /** * Generates a response based on calling the function associated with the endpoint */ -Zotero.Server.DataListener.prototype._processEndpoint = function(method, postData) { +Zotero.Server.DataListener.prototype._processEndpoint = Zotero.Promise.coroutine(function* (method, postData) { try { var endpoint = new this.endpoint; @@ -423,10 +423,54 @@ Zotero.Server.DataListener.prototype._processEndpoint = function(method, postDat me._requestFinished(me._generateResponse(code, contentType, arg)); } - // pass to endpoint - if (endpoint.init.length === 2) { + // Pass to endpoint + // + // Single-parameter endpoint + // - Takes an object with 'method', 'pathname', 'query', 'headers', and 'data' + // - Returns a status code, an array containing [statusCode, contentType, body], + // or a promise for either + if (endpoint.init.length === 1 + // Return value from Zotero.Promise.coroutine() + || endpoint.init.length === 0) { + let headers = {}; + let headerLines = this.header.trim().split(/\r\n/); + for (let line of headerLines) { + line = line.trim(); + let pos = line.indexOf(':'); + if (pos == -1) { + continue; + } + let k = line.substr(0, pos); + let v = line.substr(pos + 1).trim(); + headers[k] = v; + } + + let maybePromise = endpoint.init({ + method, + pathname: this.pathname, + query: this.query ? Zotero.Server.decodeQueryString(this.query.substr(1)) : {}, + headers, + data: decodedData + }); + let result; + if (maybePromise.then) { + result = yield maybePromise; + } + else { + result = maybePromise; + } + if (Number.isInteger(result)) { + sendResponseCallback(result); + } + else { + sendResponseCallback(...result); + } + } + // Two-parameter endpoint takes data and a callback + else if (endpoint.init.length === 2) { endpoint.init(decodedData, sendResponseCallback); } + // Three-parameter endpoint takes a URL, data, and a callback else { const uaRe = /[\r\n]User-Agent: +([^\r\n]+)/i; var m = uaRe.exec(this.header); @@ -442,7 +486,7 @@ Zotero.Server.DataListener.prototype._processEndpoint = function(method, postDat this._requestFinished(this._generateResponse(500), "text/plain", "An error occurred\n"); throw e; } -} +}); /* * returns HTTP data from a request diff --git a/chrome/content/zotero/xpcom/server_connector.js b/chrome/content/zotero/xpcom/server_connector.js index adbbe3b432..ee4a83a4b3 100644 --- a/chrome/content/zotero/xpcom/server_connector.js +++ b/chrome/content/zotero/xpcom/server_connector.js @@ -320,10 +320,10 @@ Zotero.Server.Connector.SaveItem.prototype = { /** * Either loads HTML into a hidden browser and initiates translation, or saves items directly * to the database - * @param {Object} data POST data or GET query string - * @param {Function} sendResponseCallback function to send HTTP response */ - init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback) { + init: Zotero.Promise.coroutine(function* (options) { + var data = options.data; + // figure out where to save var zp = Zotero.getActiveZoteroPane(); try { @@ -351,13 +351,12 @@ Zotero.Server.Connector.SaveItem.prototype = { } else { Zotero.logError("Can't add item to read-only library " + library.name); - sendResponseCallback(500); - return; + return 500; } } var cookieSandbox = data["uri"] ? new Zotero.CookieSandbox(null, data["uri"], - data["detailedCookies"] ? "" : data["cookie"] || "", url.userAgent) : null; + data["detailedCookies"] ? "" : data["cookie"] || "", options.userAgent) : null; if(cookieSandbox && data.detailedCookies) { cookieSandbox.addCookiesFromHeader(data.detailedCookies); } @@ -374,6 +373,7 @@ Zotero.Server.Connector.SaveItem.prototype = { forceTagType: 1, cookieSandbox }); + var deferred = Zotero.Promise.defer(); itemSaver.saveItems(data.items, function(returnValue, items) { if(returnValue) { try { @@ -387,16 +387,17 @@ Zotero.Server.Connector.SaveItem.prototype = { } } - sendResponseCallback(201, "application/json", JSON.stringify({items: data.items})); + deferred.resolve([201, "application/json", JSON.stringify({items: data.items})]); } catch(e) { Zotero.logError(e); - sendResponseCallback(500); + deferred.resolve(500); } } else { Zotero.logError(items); - sendResponseCallback(500); + deferred.resolve(500); } }, Zotero.Server.Connector.AttachmentProgressManager.onProgress); + return deferred.promise; }) } @@ -419,10 +420,10 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { /** * Save snapshot - * @param {String} data POST data or GET query string - * @param {Function} sendResponseCallback function to send HTTP response */ - init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback) { + init: Zotero.Promise.coroutine(function* (options) { + var data = options.data; + Zotero.Server.Connector.Data[data["url"]] = ""+data["html"]+""; var zp = Zotero.getActiveZoteroPane(); @@ -451,8 +452,7 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { } else { Zotero.logError("Can't add item to read-only library " + library.name); - sendResponseCallback(500); - return; + return 500; } } @@ -466,7 +466,7 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { filesEditable = true; } - var cookieSandbox = new Zotero.CookieSandbox(null, data["url"], data["cookie"], url.userAgent); + var cookieSandbox = new Zotero.CookieSandbox(null, data["url"], data["cookie"], options.userAgent); if (data.pdf && filesEditable) { delete Zotero.Server.Connector.Data[data.url]; @@ -479,14 +479,15 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { contentType: "application/pdf", cookieSandbox }); - sendResponseCallback(201) + return 201; } catch (e) { - sendResponseCallback(500); - throw e; + Zotero.logError(e); + return 500; } } else { + let deferred = Zotero.Promise.defer(); Zotero.HTTP.processDocuments( ["zotero://connector/" + encodeURIComponent(data.url)], Zotero.Promise.coroutine(function* (doc) { @@ -512,16 +513,17 @@ Zotero.Server.Connector.SaveSnapshot.prototype = { }); } - sendResponseCallback(201); + deferred.resolve(201); } catch(e) { Zotero.debug("ERROR"); Zotero.debug(e); - sendResponseCallback(500); + deferred.resolve(500); throw e; } }), null, null, false, cookieSandbox ); + return deferred.promise; } }) } @@ -600,16 +602,16 @@ Zotero.Server.Connector.Import.prototype = { supportedDataTypes: '*', permitBookmarklet: false, - init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){ + init: Zotero.Promise.coroutine(function* (options) { let translate = new Zotero.Translate.Import(); - translate.setString(data); + translate.setString(options.data); let translators = yield translate.getTranslators(); if (!translators || !translators.length) { - return sendResponseCallback(400); + return 400; } translate.setTranslator(translators[0]); let items = yield translate.translate(); - return sendResponseCallback(201, "application/json", JSON.stringify(items)); + return [201, "application/json", JSON.stringify(items)]; }) } @@ -627,13 +629,13 @@ Zotero.Server.Connector.InstallStyle.prototype = { supportedDataTypes: '*', permitBookmarklet: false, - init: Zotero.Promise.coroutine(function* (url, data, sendResponseCallback){ + init: Zotero.Promise.coroutine(function* (options) { try { - var styleName = yield Zotero.Styles.install(data, url.query.origin || null, true); + var styleName = yield Zotero.Styles.install(options.data, options.query.origin || null, true); } catch (e) { - sendResponseCallback(400, "text/plain", e.message) + return [400, "text/plain", e.message]; } - sendResponseCallback(201, "application/json", JSON.stringify({name: styleName})); + return [201, "application/json", JSON.stringify({name: styleName})]; }) }; @@ -741,16 +743,14 @@ Zotero.Server.Connector.GetClientHostnames.prototype = { /** * Returns a 200 response to say the server is alive - * @param {String} data POST data or GET query string - * @param {Function} sendResponseCallback function to send HTTP response */ - init: Zotero.Promise.coroutine(function* (url, postData, sendResponseCallback) { + init: Zotero.Promise.coroutine(function* (options) { try { var hostnames = yield Zotero.Proxies.DNS.getHostnames(); } catch(e) { - sendResponseCallback(500); + return 500; } - sendResponseCallback(200, "application/json", JSON.stringify(hostnames)); + return [200, "application/json", JSON.stringify(hostnames)]; }) }; diff --git a/test/tests/serverTest.js b/test/tests/serverTest.js new file mode 100644 index 0000000000..f12650dcc1 --- /dev/null +++ b/test/tests/serverTest.js @@ -0,0 +1,146 @@ +"use strict"; + +describe("Zotero.Server", function () { + Components.utils.import("resource://zotero-unit/httpd.js"); + var serverPath; + + before(function* () { + Zotero.Prefs.set("httpServer.enabled", true); + Zotero.Server.init(); + serverPath = 'http://127.0.0.1:' + Zotero.Prefs.get('httpServer.port'); + }); + + describe('DataListener', function() { + describe("_processEndpoint()", function () { + describe("1 argument", function () { + it("integer return", function* () { + var called = false; + + var endpoint = "/test/" + Zotero.Utilities.randomString(); + var handler = function () {}; + handler.prototype = { + supportedMethods: ["POST"], + supportedDataTypes: "*", + + init: function (options) { + called = true; + assert.isObject(options); + assert.propertyVal(options.headers, "Accept-Charset", "UTF-8"); + return 204; + } + }; + Zotero.Server.Endpoints[endpoint] = handler; + + let req = yield Zotero.HTTP.request( + "POST", + serverPath + endpoint, + { + headers: { + "Accept-Charset": "UTF-8", + "Content-Type": "application/json" + }, + responseType: "text", + body: JSON.stringify({ + foo: "bar" + }) + } + ); + + assert.ok(called); + assert.equal(req.status, 204); + }); + + it("array return", function* () { + var called = false; + + var endpoint = "/test/" + Zotero.Utilities.randomString(); + var handler = function () {}; + handler.prototype = { + supportedMethods: ["GET"], + supportedDataTypes: "*", + + init: function (options) { + called = true; + assert.isObject(options); + return [201, "text/plain", "Test"]; + } + }; + Zotero.Server.Endpoints[endpoint] = handler; + + let req = yield Zotero.HTTP.request( + "GET", + serverPath + endpoint, + { + responseType: "text" + } + ); + + assert.ok(called); + assert.equal(req.status, 201); + assert.equal(req.getResponseHeader("Content-Type"), "text/plain"); + assert.equal(req.responseText, "Test"); + }); + + it("integer promise return", function* () { + var called = false; + + var endpoint = "/test/" + Zotero.Utilities.randomString(); + var handler = function () {}; + handler.prototype = { + supportedMethods: ["GET"], + supportedDataTypes: "*", + + init: Zotero.Promise.coroutine(function* (options) { + called = true; + assert.isObject(options); + return 204; + }) + }; + Zotero.Server.Endpoints[endpoint] = handler; + + let req = yield Zotero.HTTP.request( + "GET", + serverPath + endpoint, + { + responseType: "text" + } + ); + + assert.ok(called); + assert.equal(req.status, 204); + }); + + it("array promise return", function* () { + var called = false; + + var endpoint = "/test/" + Zotero.Utilities.randomString(); + var handler = function () {}; + handler.prototype = { + supportedMethods: ["GET"], + supportedDataTypes: "*", + + init: Zotero.Promise.coroutine(function* (options) { + called = true; + assert.isObject(options); + return [201, "text/plain", "Test"]; + }) + }; + Zotero.Server.Endpoints[endpoint] = handler; + + let req = yield Zotero.HTTP.request( + "GET", + serverPath + endpoint, + { + responseType: "text" + } + ); + + assert.ok(called); + assert.equal(req.status, 201); + assert.equal(req.getResponseHeader("Content-Type"), "text/plain"); + assert.equal(req.responseText, "Test"); + }); + }); + }); + }) +});