services/sync/tests/unit/head_http_server.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

michael@0 1 const Cm = Components.manager;
michael@0 2
michael@0 3 // Shared logging for all HTTP server functions.
michael@0 4 Cu.import("resource://gre/modules/Log.jsm");
michael@0 5 const SYNC_HTTP_LOGGER = "Sync.Test.Server";
michael@0 6 const SYNC_API_VERSION = "1.1";
michael@0 7
michael@0 8 // Use the same method that record.js does, which mirrors the server.
michael@0 9 // The server returns timestamps with 1/100 sec granularity. Note that this is
michael@0 10 // subject to change: see Bug 650435.
michael@0 11 function new_timestamp() {
michael@0 12 return Math.round(Date.now() / 10) / 100;
michael@0 13 }
michael@0 14
michael@0 15 function return_timestamp(request, response, timestamp) {
michael@0 16 if (!timestamp) {
michael@0 17 timestamp = new_timestamp();
michael@0 18 }
michael@0 19 let body = "" + timestamp;
michael@0 20 response.setHeader("X-Weave-Timestamp", body);
michael@0 21 response.setStatusLine(request.httpVersion, 200, "OK");
michael@0 22 response.bodyOutputStream.write(body, body.length);
michael@0 23 return timestamp;
michael@0 24 }
michael@0 25
michael@0 26 function basic_auth_header(user, password) {
michael@0 27 return "Basic " + btoa(user + ":" + Utils.encodeUTF8(password));
michael@0 28 }
michael@0 29
michael@0 30 function basic_auth_matches(req, user, password) {
michael@0 31 if (!req.hasHeader("Authorization")) {
michael@0 32 return false;
michael@0 33 }
michael@0 34
michael@0 35 let expected = basic_auth_header(user, Utils.encodeUTF8(password));
michael@0 36 return req.getHeader("Authorization") == expected;
michael@0 37 }
michael@0 38
michael@0 39 function httpd_basic_auth_handler(body, metadata, response) {
michael@0 40 if (basic_auth_matches(metadata, "guest", "guest")) {
michael@0 41 response.setStatusLine(metadata.httpVersion, 200, "OK, authorized");
michael@0 42 response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
michael@0 43 } else {
michael@0 44 body = "This path exists and is protected - failed";
michael@0 45 response.setStatusLine(metadata.httpVersion, 401, "Unauthorized");
michael@0 46 response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
michael@0 47 }
michael@0 48 response.bodyOutputStream.write(body, body.length);
michael@0 49 }
michael@0 50
michael@0 51 /*
michael@0 52 * Represent a WBO on the server
michael@0 53 */
michael@0 54 function ServerWBO(id, initialPayload, modified) {
michael@0 55 if (!id) {
michael@0 56 throw "No ID for ServerWBO!";
michael@0 57 }
michael@0 58 this.id = id;
michael@0 59 if (!initialPayload) {
michael@0 60 return;
michael@0 61 }
michael@0 62
michael@0 63 if (typeof initialPayload == "object") {
michael@0 64 initialPayload = JSON.stringify(initialPayload);
michael@0 65 }
michael@0 66 this.payload = initialPayload;
michael@0 67 this.modified = modified || new_timestamp();
michael@0 68 }
michael@0 69 ServerWBO.prototype = {
michael@0 70
michael@0 71 get data() {
michael@0 72 return JSON.parse(this.payload);
michael@0 73 },
michael@0 74
michael@0 75 get: function() {
michael@0 76 return JSON.stringify(this, ["id", "modified", "payload"]);
michael@0 77 },
michael@0 78
michael@0 79 put: function(input) {
michael@0 80 input = JSON.parse(input);
michael@0 81 this.payload = input.payload;
michael@0 82 this.modified = new_timestamp();
michael@0 83 },
michael@0 84
michael@0 85 delete: function() {
michael@0 86 delete this.payload;
michael@0 87 delete this.modified;
michael@0 88 },
michael@0 89
michael@0 90 // This handler sets `newModified` on the response body if the collection
michael@0 91 // timestamp has changed. This allows wrapper handlers to extract information
michael@0 92 // that otherwise would exist only in the body stream.
michael@0 93 handler: function() {
michael@0 94 let self = this;
michael@0 95
michael@0 96 return function(request, response) {
michael@0 97 var statusCode = 200;
michael@0 98 var status = "OK";
michael@0 99 var body;
michael@0 100
michael@0 101 switch(request.method) {
michael@0 102 case "GET":
michael@0 103 if (self.payload) {
michael@0 104 body = self.get();
michael@0 105 } else {
michael@0 106 statusCode = 404;
michael@0 107 status = "Not Found";
michael@0 108 body = "Not Found";
michael@0 109 }
michael@0 110 break;
michael@0 111
michael@0 112 case "PUT":
michael@0 113 self.put(readBytesFromInputStream(request.bodyInputStream));
michael@0 114 body = JSON.stringify(self.modified);
michael@0 115 response.setHeader("Content-Type", "application/json");
michael@0 116 response.newModified = self.modified;
michael@0 117 break;
michael@0 118
michael@0 119 case "DELETE":
michael@0 120 self.delete();
michael@0 121 let ts = new_timestamp();
michael@0 122 body = JSON.stringify(ts);
michael@0 123 response.setHeader("Content-Type", "application/json");
michael@0 124 response.newModified = ts;
michael@0 125 break;
michael@0 126 }
michael@0 127 response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false);
michael@0 128 response.setStatusLine(request.httpVersion, statusCode, status);
michael@0 129 response.bodyOutputStream.write(body, body.length);
michael@0 130 };
michael@0 131 }
michael@0 132
michael@0 133 };
michael@0 134
michael@0 135
michael@0 136 /**
michael@0 137 * Represent a collection on the server. The '_wbos' attribute is a
michael@0 138 * mapping of id -> ServerWBO objects.
michael@0 139 *
michael@0 140 * Note that if you want these records to be accessible individually,
michael@0 141 * you need to register their handlers with the server separately, or use a
michael@0 142 * containing HTTP server that will do so on your behalf.
michael@0 143 *
michael@0 144 * @param wbos
michael@0 145 * An object mapping WBO IDs to ServerWBOs.
michael@0 146 * @param acceptNew
michael@0 147 * If true, POSTs to this collection URI will result in new WBOs being
michael@0 148 * created and wired in on the fly.
michael@0 149 * @param timestamp
michael@0 150 * An optional timestamp value to initialize the modified time of the
michael@0 151 * collection. This should be in the format returned by new_timestamp().
michael@0 152 *
michael@0 153 * @return the new ServerCollection instance.
michael@0 154 *
michael@0 155 */
michael@0 156 function ServerCollection(wbos, acceptNew, timestamp) {
michael@0 157 this._wbos = wbos || {};
michael@0 158 this.acceptNew = acceptNew || false;
michael@0 159
michael@0 160 /*
michael@0 161 * Track modified timestamp.
michael@0 162 * We can't just use the timestamps of contained WBOs: an empty collection
michael@0 163 * has a modified time.
michael@0 164 */
michael@0 165 this.timestamp = timestamp || new_timestamp();
michael@0 166 this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER);
michael@0 167 }
michael@0 168 ServerCollection.prototype = {
michael@0 169
michael@0 170 /**
michael@0 171 * Convenience accessor for our WBO keys.
michael@0 172 * Excludes deleted items, of course.
michael@0 173 *
michael@0 174 * @param filter
michael@0 175 * A predicate function (applied to the ID and WBO) which dictates
michael@0 176 * whether to include the WBO's ID in the output.
michael@0 177 *
michael@0 178 * @return an array of IDs.
michael@0 179 */
michael@0 180 keys: function keys(filter) {
michael@0 181 return [id for ([id, wbo] in Iterator(this._wbos))
michael@0 182 if (wbo.payload &&
michael@0 183 (!filter || filter(id, wbo)))];
michael@0 184 },
michael@0 185
michael@0 186 /**
michael@0 187 * Convenience method to get an array of WBOs.
michael@0 188 * Optionally provide a filter function.
michael@0 189 *
michael@0 190 * @param filter
michael@0 191 * A predicate function, applied to the WBO, which dictates whether to
michael@0 192 * include the WBO in the output.
michael@0 193 *
michael@0 194 * @return an array of ServerWBOs.
michael@0 195 */
michael@0 196 wbos: function wbos(filter) {
michael@0 197 let os = [wbo for ([id, wbo] in Iterator(this._wbos))
michael@0 198 if (wbo.payload)];
michael@0 199 if (filter) {
michael@0 200 return os.filter(filter);
michael@0 201 }
michael@0 202 return os;
michael@0 203 },
michael@0 204
michael@0 205 /**
michael@0 206 * Convenience method to get an array of parsed ciphertexts.
michael@0 207 *
michael@0 208 * @return an array of the payloads of each stored WBO.
michael@0 209 */
michael@0 210 payloads: function () {
michael@0 211 return this.wbos().map(function (wbo) {
michael@0 212 return JSON.parse(JSON.parse(wbo.payload).ciphertext);
michael@0 213 });
michael@0 214 },
michael@0 215
michael@0 216 // Just for syntactic elegance.
michael@0 217 wbo: function wbo(id) {
michael@0 218 return this._wbos[id];
michael@0 219 },
michael@0 220
michael@0 221 payload: function payload(id) {
michael@0 222 return this.wbo(id).payload;
michael@0 223 },
michael@0 224
michael@0 225 /**
michael@0 226 * Insert the provided WBO under its ID.
michael@0 227 *
michael@0 228 * @return the provided WBO.
michael@0 229 */
michael@0 230 insertWBO: function insertWBO(wbo) {
michael@0 231 return this._wbos[wbo.id] = wbo;
michael@0 232 },
michael@0 233
michael@0 234 /**
michael@0 235 * Insert the provided payload as part of a new ServerWBO with the provided
michael@0 236 * ID.
michael@0 237 *
michael@0 238 * @param id
michael@0 239 * The GUID for the WBO.
michael@0 240 * @param payload
michael@0 241 * The payload, as provided to the ServerWBO constructor.
michael@0 242 * @param modified
michael@0 243 * An optional modified time for the ServerWBO.
michael@0 244 *
michael@0 245 * @return the inserted WBO.
michael@0 246 */
michael@0 247 insert: function insert(id, payload, modified) {
michael@0 248 return this.insertWBO(new ServerWBO(id, payload, modified));
michael@0 249 },
michael@0 250
michael@0 251 /**
michael@0 252 * Removes an object entirely from the collection.
michael@0 253 *
michael@0 254 * @param id
michael@0 255 * (string) ID to remove.
michael@0 256 */
michael@0 257 remove: function remove(id) {
michael@0 258 delete this._wbos[id];
michael@0 259 },
michael@0 260
michael@0 261 _inResultSet: function(wbo, options) {
michael@0 262 return wbo.payload
michael@0 263 && (!options.ids || (options.ids.indexOf(wbo.id) != -1))
michael@0 264 && (!options.newer || (wbo.modified > options.newer));
michael@0 265 },
michael@0 266
michael@0 267 count: function(options) {
michael@0 268 options = options || {};
michael@0 269 let c = 0;
michael@0 270 for (let [id, wbo] in Iterator(this._wbos)) {
michael@0 271 if (wbo.modified && this._inResultSet(wbo, options)) {
michael@0 272 c++;
michael@0 273 }
michael@0 274 }
michael@0 275 return c;
michael@0 276 },
michael@0 277
michael@0 278 get: function(options) {
michael@0 279 let result;
michael@0 280 if (options.full) {
michael@0 281 let data = [wbo.get() for ([id, wbo] in Iterator(this._wbos))
michael@0 282 // Drop deleted.
michael@0 283 if (wbo.modified &&
michael@0 284 this._inResultSet(wbo, options))];
michael@0 285 if (options.limit) {
michael@0 286 data = data.slice(0, options.limit);
michael@0 287 }
michael@0 288 // Our implementation of application/newlines.
michael@0 289 result = data.join("\n") + "\n";
michael@0 290
michael@0 291 // Use options as a backchannel to report count.
michael@0 292 options.recordCount = data.length;
michael@0 293 } else {
michael@0 294 let data = [id for ([id, wbo] in Iterator(this._wbos))
michael@0 295 if (this._inResultSet(wbo, options))];
michael@0 296 if (options.limit) {
michael@0 297 data = data.slice(0, options.limit);
michael@0 298 }
michael@0 299 result = JSON.stringify(data);
michael@0 300 options.recordCount = data.length;
michael@0 301 }
michael@0 302 return result;
michael@0 303 },
michael@0 304
michael@0 305 post: function(input) {
michael@0 306 input = JSON.parse(input);
michael@0 307 let success = [];
michael@0 308 let failed = {};
michael@0 309
michael@0 310 // This will count records where we have an existing ServerWBO
michael@0 311 // registered with us as successful and all other records as failed.
michael@0 312 for each (let record in input) {
michael@0 313 let wbo = this.wbo(record.id);
michael@0 314 if (!wbo && this.acceptNew) {
michael@0 315 this._log.debug("Creating WBO " + JSON.stringify(record.id) +
michael@0 316 " on the fly.");
michael@0 317 wbo = new ServerWBO(record.id);
michael@0 318 this.insertWBO(wbo);
michael@0 319 }
michael@0 320 if (wbo) {
michael@0 321 wbo.payload = record.payload;
michael@0 322 wbo.modified = new_timestamp();
michael@0 323 success.push(record.id);
michael@0 324 } else {
michael@0 325 failed[record.id] = "no wbo configured";
michael@0 326 }
michael@0 327 }
michael@0 328 return {modified: new_timestamp(),
michael@0 329 success: success,
michael@0 330 failed: failed};
michael@0 331 },
michael@0 332
michael@0 333 delete: function(options) {
michael@0 334 let deleted = [];
michael@0 335 for (let [id, wbo] in Iterator(this._wbos)) {
michael@0 336 if (this._inResultSet(wbo, options)) {
michael@0 337 this._log.debug("Deleting " + JSON.stringify(wbo));
michael@0 338 deleted.push(wbo.id);
michael@0 339 wbo.delete();
michael@0 340 }
michael@0 341 }
michael@0 342 return deleted;
michael@0 343 },
michael@0 344
michael@0 345 // This handler sets `newModified` on the response body if the collection
michael@0 346 // timestamp has changed.
michael@0 347 handler: function() {
michael@0 348 let self = this;
michael@0 349
michael@0 350 return function(request, response) {
michael@0 351 var statusCode = 200;
michael@0 352 var status = "OK";
michael@0 353 var body;
michael@0 354
michael@0 355 // Parse queryString
michael@0 356 let options = {};
michael@0 357 for each (let chunk in request.queryString.split("&")) {
michael@0 358 if (!chunk) {
michael@0 359 continue;
michael@0 360 }
michael@0 361 chunk = chunk.split("=");
michael@0 362 if (chunk.length == 1) {
michael@0 363 options[chunk[0]] = "";
michael@0 364 } else {
michael@0 365 options[chunk[0]] = chunk[1];
michael@0 366 }
michael@0 367 }
michael@0 368 if (options.ids) {
michael@0 369 options.ids = options.ids.split(",");
michael@0 370 }
michael@0 371 if (options.newer) {
michael@0 372 options.newer = parseFloat(options.newer);
michael@0 373 }
michael@0 374 if (options.limit) {
michael@0 375 options.limit = parseInt(options.limit, 10);
michael@0 376 }
michael@0 377
michael@0 378 switch(request.method) {
michael@0 379 case "GET":
michael@0 380 body = self.get(options);
michael@0 381 // "If supported by the db, this header will return the number of
michael@0 382 // records total in the request body of any multiple-record GET
michael@0 383 // request."
michael@0 384 let records = options.recordCount;
michael@0 385 self._log.info("Records: " + records);
michael@0 386 if (records != null) {
michael@0 387 response.setHeader("X-Weave-Records", "" + records);
michael@0 388 }
michael@0 389 break;
michael@0 390
michael@0 391 case "POST":
michael@0 392 let res = self.post(readBytesFromInputStream(request.bodyInputStream));
michael@0 393 body = JSON.stringify(res);
michael@0 394 response.newModified = res.modified;
michael@0 395 break;
michael@0 396
michael@0 397 case "DELETE":
michael@0 398 self._log.debug("Invoking ServerCollection.DELETE.");
michael@0 399 let deleted = self.delete(options);
michael@0 400 let ts = new_timestamp();
michael@0 401 body = JSON.stringify(ts);
michael@0 402 response.newModified = ts;
michael@0 403 response.deleted = deleted;
michael@0 404 break;
michael@0 405 }
michael@0 406 response.setHeader("X-Weave-Timestamp",
michael@0 407 "" + new_timestamp(),
michael@0 408 false);
michael@0 409 response.setStatusLine(request.httpVersion, statusCode, status);
michael@0 410 response.bodyOutputStream.write(body, body.length);
michael@0 411
michael@0 412 // Update the collection timestamp to the appropriate modified time.
michael@0 413 // This is either a value set by the handler, or the current time.
michael@0 414 if (request.method != "GET") {
michael@0 415 this.timestamp = (response.newModified >= 0) ?
michael@0 416 response.newModified :
michael@0 417 new_timestamp();
michael@0 418 }
michael@0 419 };
michael@0 420 }
michael@0 421
michael@0 422 };
michael@0 423
michael@0 424 /*
michael@0 425 * Test setup helpers.
michael@0 426 */
michael@0 427 function sync_httpd_setup(handlers) {
michael@0 428 handlers["/1.1/foo/storage/meta/global"]
michael@0 429 = (new ServerWBO("global", {})).handler();
michael@0 430 return httpd_setup(handlers);
michael@0 431 }
michael@0 432
michael@0 433 /*
michael@0 434 * Track collection modified times. Return closures.
michael@0 435 */
michael@0 436 function track_collections_helper() {
michael@0 437
michael@0 438 /*
michael@0 439 * Our tracking object.
michael@0 440 */
michael@0 441 let collections = {};
michael@0 442
michael@0 443 /*
michael@0 444 * Update the timestamp of a collection.
michael@0 445 */
michael@0 446 function update_collection(coll, ts) {
michael@0 447 _("Updating collection " + coll + " to " + ts);
michael@0 448 let timestamp = ts || new_timestamp();
michael@0 449 collections[coll] = timestamp;
michael@0 450 }
michael@0 451
michael@0 452 /*
michael@0 453 * Invoke a handler, updating the collection's modified timestamp unless
michael@0 454 * it's a GET request.
michael@0 455 */
michael@0 456 function with_updated_collection(coll, f) {
michael@0 457 return function(request, response) {
michael@0 458 f.call(this, request, response);
michael@0 459
michael@0 460 // Update the collection timestamp to the appropriate modified time.
michael@0 461 // This is either a value set by the handler, or the current time.
michael@0 462 if (request.method != "GET") {
michael@0 463 update_collection(coll, response.newModified)
michael@0 464 }
michael@0 465 };
michael@0 466 }
michael@0 467
michael@0 468 /*
michael@0 469 * Return the info/collections object.
michael@0 470 */
michael@0 471 function info_collections(request, response) {
michael@0 472 let body = "Error.";
michael@0 473 switch(request.method) {
michael@0 474 case "GET":
michael@0 475 body = JSON.stringify(collections);
michael@0 476 break;
michael@0 477 default:
michael@0 478 throw "Non-GET on info_collections.";
michael@0 479 }
michael@0 480
michael@0 481 response.setHeader("Content-Type", "application/json");
michael@0 482 response.setHeader("X-Weave-Timestamp",
michael@0 483 "" + new_timestamp(),
michael@0 484 false);
michael@0 485 response.setStatusLine(request.httpVersion, 200, "OK");
michael@0 486 response.bodyOutputStream.write(body, body.length);
michael@0 487 }
michael@0 488
michael@0 489 return {"collections": collections,
michael@0 490 "handler": info_collections,
michael@0 491 "with_updated_collection": with_updated_collection,
michael@0 492 "update_collection": update_collection};
michael@0 493 }
michael@0 494
michael@0 495 //===========================================================================//
michael@0 496 // httpd.js-based Sync server. //
michael@0 497 //===========================================================================//
michael@0 498
michael@0 499 /**
michael@0 500 * In general, the preferred way of using SyncServer is to directly introspect
michael@0 501 * it. Callbacks are available for operations which are hard to verify through
michael@0 502 * introspection, such as deletions.
michael@0 503 *
michael@0 504 * One of the goals of this server is to provide enough hooks for test code to
michael@0 505 * find out what it needs without monkeypatching. Use this object as your
michael@0 506 * prototype, and override as appropriate.
michael@0 507 */
michael@0 508 let SyncServerCallback = {
michael@0 509 onCollectionDeleted: function onCollectionDeleted(user, collection) {},
michael@0 510 onItemDeleted: function onItemDeleted(user, collection, wboID) {},
michael@0 511
michael@0 512 /**
michael@0 513 * Called at the top of every request.
michael@0 514 *
michael@0 515 * Allows the test to inspect the request. Hooks should be careful not to
michael@0 516 * modify or change state of the request or they may impact future processing.
michael@0 517 */
michael@0 518 onRequest: function onRequest(request) {},
michael@0 519 };
michael@0 520
michael@0 521 /**
michael@0 522 * Construct a new test Sync server. Takes a callback object (e.g.,
michael@0 523 * SyncServerCallback) as input.
michael@0 524 */
michael@0 525 function SyncServer(callback) {
michael@0 526 this.callback = callback || {__proto__: SyncServerCallback};
michael@0 527 this.server = new HttpServer();
michael@0 528 this.started = false;
michael@0 529 this.users = {};
michael@0 530 this._log = Log.repository.getLogger(SYNC_HTTP_LOGGER);
michael@0 531
michael@0 532 // Install our own default handler. This allows us to mess around with the
michael@0 533 // whole URL space.
michael@0 534 let handler = this.server._handler;
michael@0 535 handler._handleDefault = this.handleDefault.bind(this, handler);
michael@0 536 }
michael@0 537 SyncServer.prototype = {
michael@0 538 server: null, // HttpServer.
michael@0 539 users: null, // Map of username => {collections, password}.
michael@0 540
michael@0 541 /**
michael@0 542 * Start the SyncServer's underlying HTTP server.
michael@0 543 *
michael@0 544 * @param port
michael@0 545 * The numeric port on which to start. A falsy value implies the
michael@0 546 * default, a randomly chosen port.
michael@0 547 * @param cb
michael@0 548 * A callback function (of no arguments) which is invoked after
michael@0 549 * startup.
michael@0 550 */
michael@0 551 start: function start(port, cb) {
michael@0 552 if (this.started) {
michael@0 553 this._log.warn("Warning: server already started on " + this.port);
michael@0 554 return;
michael@0 555 }
michael@0 556 try {
michael@0 557 this.server.start(port);
michael@0 558 let i = this.server.identity;
michael@0 559 this.port = i.primaryPort;
michael@0 560 this.baseURI = i.primaryScheme + "://" + i.primaryHost + ":" +
michael@0 561 i.primaryPort + "/";
michael@0 562 this.started = true;
michael@0 563 if (cb) {
michael@0 564 cb();
michael@0 565 }
michael@0 566 } catch (ex) {
michael@0 567 _("==========================================");
michael@0 568 _("Got exception starting Sync HTTP server.");
michael@0 569 _("Error: " + Utils.exceptionStr(ex));
michael@0 570 _("Is there a process already listening on port " + port + "?");
michael@0 571 _("==========================================");
michael@0 572 do_throw(ex);
michael@0 573 }
michael@0 574
michael@0 575 },
michael@0 576
michael@0 577 /**
michael@0 578 * Stop the SyncServer's HTTP server.
michael@0 579 *
michael@0 580 * @param cb
michael@0 581 * A callback function. Invoked after the server has been stopped.
michael@0 582 *
michael@0 583 */
michael@0 584 stop: function stop(cb) {
michael@0 585 if (!this.started) {
michael@0 586 this._log.warn("SyncServer: Warning: server not running. Can't stop me now!");
michael@0 587 return;
michael@0 588 }
michael@0 589
michael@0 590 this.server.stop(cb);
michael@0 591 this.started = false;
michael@0 592 },
michael@0 593
michael@0 594 /**
michael@0 595 * Return a server timestamp for a record.
michael@0 596 * The server returns timestamps with 1/100 sec granularity. Note that this is
michael@0 597 * subject to change: see Bug 650435.
michael@0 598 */
michael@0 599 timestamp: function timestamp() {
michael@0 600 return new_timestamp();
michael@0 601 },
michael@0 602
michael@0 603 /**
michael@0 604 * Create a new user, complete with an empty set of collections.
michael@0 605 *
michael@0 606 * @param username
michael@0 607 * The username to use. An Error will be thrown if a user by that name
michael@0 608 * already exists.
michael@0 609 * @param password
michael@0 610 * A password string.
michael@0 611 *
michael@0 612 * @return a user object, as would be returned by server.user(username).
michael@0 613 */
michael@0 614 registerUser: function registerUser(username, password) {
michael@0 615 if (username in this.users) {
michael@0 616 throw new Error("User already exists.");
michael@0 617 }
michael@0 618 this.users[username] = {
michael@0 619 password: password,
michael@0 620 collections: {}
michael@0 621 };
michael@0 622 return this.user(username);
michael@0 623 },
michael@0 624
michael@0 625 userExists: function userExists(username) {
michael@0 626 return username in this.users;
michael@0 627 },
michael@0 628
michael@0 629 getCollection: function getCollection(username, collection) {
michael@0 630 return this.users[username].collections[collection];
michael@0 631 },
michael@0 632
michael@0 633 _insertCollection: function _insertCollection(collections, collection, wbos) {
michael@0 634 let coll = new ServerCollection(wbos, true);
michael@0 635 coll.collectionHandler = coll.handler();
michael@0 636 collections[collection] = coll;
michael@0 637 return coll;
michael@0 638 },
michael@0 639
michael@0 640 createCollection: function createCollection(username, collection, wbos) {
michael@0 641 if (!(username in this.users)) {
michael@0 642 throw new Error("Unknown user.");
michael@0 643 }
michael@0 644 let collections = this.users[username].collections;
michael@0 645 if (collection in collections) {
michael@0 646 throw new Error("Collection already exists.");
michael@0 647 }
michael@0 648 return this._insertCollection(collections, collection, wbos);
michael@0 649 },
michael@0 650
michael@0 651 /**
michael@0 652 * Accept a map like the following:
michael@0 653 * {
michael@0 654 * meta: {global: {version: 1, ...}},
michael@0 655 * crypto: {"keys": {}, foo: {bar: 2}},
michael@0 656 * bookmarks: {}
michael@0 657 * }
michael@0 658 * to cause collections and WBOs to be created.
michael@0 659 * If a collection already exists, no error is raised.
michael@0 660 * If a WBO already exists, it will be updated to the new contents.
michael@0 661 */
michael@0 662 createContents: function createContents(username, collections) {
michael@0 663 if (!(username in this.users)) {
michael@0 664 throw new Error("Unknown user.");
michael@0 665 }
michael@0 666 let userCollections = this.users[username].collections;
michael@0 667 for (let [id, contents] in Iterator(collections)) {
michael@0 668 let coll = userCollections[id] ||
michael@0 669 this._insertCollection(userCollections, id);
michael@0 670 for (let [wboID, payload] in Iterator(contents)) {
michael@0 671 coll.insert(wboID, payload);
michael@0 672 }
michael@0 673 }
michael@0 674 },
michael@0 675
michael@0 676 /**
michael@0 677 * Insert a WBO in an existing collection.
michael@0 678 */
michael@0 679 insertWBO: function insertWBO(username, collection, wbo) {
michael@0 680 if (!(username in this.users)) {
michael@0 681 throw new Error("Unknown user.");
michael@0 682 }
michael@0 683 let userCollections = this.users[username].collections;
michael@0 684 if (!(collection in userCollections)) {
michael@0 685 throw new Error("Unknown collection.");
michael@0 686 }
michael@0 687 userCollections[collection].insertWBO(wbo);
michael@0 688 return wbo;
michael@0 689 },
michael@0 690
michael@0 691 /**
michael@0 692 * Delete all of the collections for the named user.
michael@0 693 *
michael@0 694 * @param username
michael@0 695 * The name of the affected user.
michael@0 696 *
michael@0 697 * @return a timestamp.
michael@0 698 */
michael@0 699 deleteCollections: function deleteCollections(username) {
michael@0 700 if (!(username in this.users)) {
michael@0 701 throw new Error("Unknown user.");
michael@0 702 }
michael@0 703 let userCollections = this.users[username].collections;
michael@0 704 for each (let [name, coll] in Iterator(userCollections)) {
michael@0 705 this._log.trace("Bulk deleting " + name + " for " + username + "...");
michael@0 706 coll.delete({});
michael@0 707 }
michael@0 708 this.users[username].collections = {};
michael@0 709 return this.timestamp();
michael@0 710 },
michael@0 711
michael@0 712 /**
michael@0 713 * Simple accessor to allow collective binding and abbreviation of a bunch of
michael@0 714 * methods. Yay!
michael@0 715 * Use like this:
michael@0 716 *
michael@0 717 * let u = server.user("john");
michael@0 718 * u.collection("bookmarks").wbo("abcdefg").payload; // Etc.
michael@0 719 *
michael@0 720 * @return a proxy for the user data stored in this server.
michael@0 721 */
michael@0 722 user: function user(username) {
michael@0 723 let collection = this.getCollection.bind(this, username);
michael@0 724 let createCollection = this.createCollection.bind(this, username);
michael@0 725 let createContents = this.createContents.bind(this, username);
michael@0 726 let modified = function (collectionName) {
michael@0 727 return collection(collectionName).timestamp;
michael@0 728 }
michael@0 729 let deleteCollections = this.deleteCollections.bind(this, username);
michael@0 730 return {
michael@0 731 collection: collection,
michael@0 732 createCollection: createCollection,
michael@0 733 createContents: createContents,
michael@0 734 deleteCollections: deleteCollections,
michael@0 735 modified: modified
michael@0 736 };
michael@0 737 },
michael@0 738
michael@0 739 /*
michael@0 740 * Regular expressions for splitting up Sync request paths.
michael@0 741 * Sync URLs are of the form:
michael@0 742 * /$apipath/$version/$user/$further
michael@0 743 * where $further is usually:
michael@0 744 * storage/$collection/$wbo
michael@0 745 * or
michael@0 746 * storage/$collection
michael@0 747 * or
michael@0 748 * info/$op
michael@0 749 * We assume for the sake of simplicity that $apipath is empty.
michael@0 750 *
michael@0 751 * N.B., we don't follow any kind of username spec here, because as far as I
michael@0 752 * can tell there isn't one. See Bug 689671. Instead we follow the Python
michael@0 753 * server code.
michael@0 754 *
michael@0 755 * Path: [all, version, username, first, rest]
michael@0 756 * Storage: [all, collection?, id?]
michael@0 757 */
michael@0 758 pathRE: /^\/([0-9]+(?:\.[0-9]+)?)\/([-._a-zA-Z0-9]+)(?:\/([^\/]+)(?:\/(.+))?)?$/,
michael@0 759 storageRE: /^([-_a-zA-Z0-9]+)(?:\/([-_a-zA-Z0-9]+)\/?)?$/,
michael@0 760
michael@0 761 defaultHeaders: {},
michael@0 762
michael@0 763 /**
michael@0 764 * HTTP response utility.
michael@0 765 */
michael@0 766 respond: function respond(req, resp, code, status, body, headers) {
michael@0 767 resp.setStatusLine(req.httpVersion, code, status);
michael@0 768 for each (let [header, value] in Iterator(headers || this.defaultHeaders)) {
michael@0 769 resp.setHeader(header, value);
michael@0 770 }
michael@0 771 resp.setHeader("X-Weave-Timestamp", "" + this.timestamp(), false);
michael@0 772 resp.bodyOutputStream.write(body, body.length);
michael@0 773 },
michael@0 774
michael@0 775 /**
michael@0 776 * This is invoked by the HttpServer. `this` is bound to the SyncServer;
michael@0 777 * `handler` is the HttpServer's handler.
michael@0 778 *
michael@0 779 * TODO: need to use the correct Sync API response codes and errors here.
michael@0 780 * TODO: Basic Auth.
michael@0 781 * TODO: check username in path against username in BasicAuth.
michael@0 782 */
michael@0 783 handleDefault: function handleDefault(handler, req, resp) {
michael@0 784 try {
michael@0 785 this._handleDefault(handler, req, resp);
michael@0 786 } catch (e) {
michael@0 787 if (e instanceof HttpError) {
michael@0 788 this.respond(req, resp, e.code, e.description, "", {});
michael@0 789 } else {
michael@0 790 throw e;
michael@0 791 }
michael@0 792 }
michael@0 793 },
michael@0 794
michael@0 795 _handleDefault: function _handleDefault(handler, req, resp) {
michael@0 796 this._log.debug("SyncServer: Handling request: " + req.method + " " + req.path);
michael@0 797
michael@0 798 if (this.callback.onRequest) {
michael@0 799 this.callback.onRequest(req);
michael@0 800 }
michael@0 801
michael@0 802 let parts = this.pathRE.exec(req.path);
michael@0 803 if (!parts) {
michael@0 804 this._log.debug("SyncServer: Unexpected request: bad URL " + req.path);
michael@0 805 throw HTTP_404;
michael@0 806 }
michael@0 807
michael@0 808 let [all, version, username, first, rest] = parts;
michael@0 809 // Doing a float compare of the version allows for us to pretend there was
michael@0 810 // a node-reassignment - eg, we could re-assign from "1.1/user/" to
michael@0 811 // "1.10/user" - this server will then still accept requests with the new
michael@0 812 // URL while any code in sync itself which compares URLs will see a
michael@0 813 // different URL.
michael@0 814 if (parseFloat(version) != parseFloat(SYNC_API_VERSION)) {
michael@0 815 this._log.debug("SyncServer: Unknown version.");
michael@0 816 throw HTTP_404;
michael@0 817 }
michael@0 818
michael@0 819 if (!this.userExists(username)) {
michael@0 820 this._log.debug("SyncServer: Unknown user.");
michael@0 821 throw HTTP_401;
michael@0 822 }
michael@0 823
michael@0 824 // Hand off to the appropriate handler for this path component.
michael@0 825 if (first in this.toplevelHandlers) {
michael@0 826 let handler = this.toplevelHandlers[first];
michael@0 827 return handler.call(this, handler, req, resp, version, username, rest);
michael@0 828 }
michael@0 829 this._log.debug("SyncServer: Unknown top-level " + first);
michael@0 830 throw HTTP_404;
michael@0 831 },
michael@0 832
michael@0 833 /**
michael@0 834 * Compute the object that is returned for an info/collections request.
michael@0 835 */
michael@0 836 infoCollections: function infoCollections(username) {
michael@0 837 let responseObject = {};
michael@0 838 let colls = this.users[username].collections;
michael@0 839 for (let coll in colls) {
michael@0 840 responseObject[coll] = colls[coll].timestamp;
michael@0 841 }
michael@0 842 this._log.trace("SyncServer: info/collections returning " +
michael@0 843 JSON.stringify(responseObject));
michael@0 844 return responseObject;
michael@0 845 },
michael@0 846
michael@0 847 /**
michael@0 848 * Collection of the handler methods we use for top-level path components.
michael@0 849 */
michael@0 850 toplevelHandlers: {
michael@0 851 "storage": function handleStorage(handler, req, resp, version, username, rest) {
michael@0 852 let respond = this.respond.bind(this, req, resp);
michael@0 853 if (!rest || !rest.length) {
michael@0 854 this._log.debug("SyncServer: top-level storage " +
michael@0 855 req.method + " request.");
michael@0 856
michael@0 857 // TODO: verify if this is spec-compliant.
michael@0 858 if (req.method != "DELETE") {
michael@0 859 respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"});
michael@0 860 return undefined;
michael@0 861 }
michael@0 862
michael@0 863 // Delete all collections and track the timestamp for the response.
michael@0 864 let timestamp = this.user(username).deleteCollections();
michael@0 865
michael@0 866 // Return timestamp and OK for deletion.
michael@0 867 respond(200, "OK", JSON.stringify(timestamp));
michael@0 868 return undefined;
michael@0 869 }
michael@0 870
michael@0 871 let match = this.storageRE.exec(rest);
michael@0 872 if (!match) {
michael@0 873 this._log.warn("SyncServer: Unknown storage operation " + rest);
michael@0 874 throw HTTP_404;
michael@0 875 }
michael@0 876 let [all, collection, wboID] = match;
michael@0 877 let coll = this.getCollection(username, collection);
michael@0 878 switch (req.method) {
michael@0 879 case "GET":
michael@0 880 if (!coll) {
michael@0 881 if (wboID) {
michael@0 882 respond(404, "Not found", "Not found");
michael@0 883 return undefined;
michael@0 884 }
michael@0 885 // *cries inside*: Bug 687299.
michael@0 886 respond(200, "OK", "[]");
michael@0 887 return undefined;
michael@0 888 }
michael@0 889 if (!wboID) {
michael@0 890 return coll.collectionHandler(req, resp);
michael@0 891 }
michael@0 892 let wbo = coll.wbo(wboID);
michael@0 893 if (!wbo) {
michael@0 894 respond(404, "Not found", "Not found");
michael@0 895 return undefined;
michael@0 896 }
michael@0 897 return wbo.handler()(req, resp);
michael@0 898
michael@0 899 // TODO: implement handling of X-If-Unmodified-Since for write verbs.
michael@0 900 case "DELETE":
michael@0 901 if (!coll) {
michael@0 902 respond(200, "OK", "{}");
michael@0 903 return undefined;
michael@0 904 }
michael@0 905 if (wboID) {
michael@0 906 let wbo = coll.wbo(wboID);
michael@0 907 if (wbo) {
michael@0 908 wbo.delete();
michael@0 909 this.callback.onItemDeleted(username, collection, wboID);
michael@0 910 }
michael@0 911 respond(200, "OK", "{}");
michael@0 912 return undefined;
michael@0 913 }
michael@0 914 coll.collectionHandler(req, resp);
michael@0 915
michael@0 916 // Spot if this is a DELETE for some IDs, and don't blow away the
michael@0 917 // whole collection!
michael@0 918 //
michael@0 919 // We already handled deleting the WBOs by invoking the deleted
michael@0 920 // collection's handler. However, in the case of
michael@0 921 //
michael@0 922 // DELETE storage/foobar
michael@0 923 //
michael@0 924 // we also need to remove foobar from the collections map. This
michael@0 925 // clause tries to differentiate the above request from
michael@0 926 //
michael@0 927 // DELETE storage/foobar?ids=foo,baz
michael@0 928 //
michael@0 929 // and do the right thing.
michael@0 930 // TODO: less hacky method.
michael@0 931 if (-1 == req.queryString.indexOf("ids=")) {
michael@0 932 // When you delete the entire collection, we drop it.
michael@0 933 this._log.debug("Deleting entire collection.");
michael@0 934 delete this.users[username].collections[collection];
michael@0 935 this.callback.onCollectionDeleted(username, collection);
michael@0 936 }
michael@0 937
michael@0 938 // Notify of item deletion.
michael@0 939 let deleted = resp.deleted || [];
michael@0 940 for (let i = 0; i < deleted.length; ++i) {
michael@0 941 this.callback.onItemDeleted(username, collection, deleted[i]);
michael@0 942 }
michael@0 943 return undefined;
michael@0 944 case "POST":
michael@0 945 case "PUT":
michael@0 946 if (!coll) {
michael@0 947 coll = this.createCollection(username, collection);
michael@0 948 }
michael@0 949 if (wboID) {
michael@0 950 let wbo = coll.wbo(wboID);
michael@0 951 if (!wbo) {
michael@0 952 this._log.trace("SyncServer: creating WBO " + collection + "/" + wboID);
michael@0 953 wbo = coll.insert(wboID);
michael@0 954 }
michael@0 955 // Rather than instantiate each WBO's handler function, do it once
michael@0 956 // per request. They get hit far less often than do collections.
michael@0 957 wbo.handler()(req, resp);
michael@0 958 coll.timestamp = resp.newModified;
michael@0 959 return resp;
michael@0 960 }
michael@0 961 return coll.collectionHandler(req, resp);
michael@0 962 default:
michael@0 963 throw "Request method " + req.method + " not implemented.";
michael@0 964 }
michael@0 965 },
michael@0 966
michael@0 967 "info": function handleInfo(handler, req, resp, version, username, rest) {
michael@0 968 switch (rest) {
michael@0 969 case "collections":
michael@0 970 let body = JSON.stringify(this.infoCollections(username));
michael@0 971 this.respond(req, resp, 200, "OK", body, {
michael@0 972 "Content-Type": "application/json"
michael@0 973 });
michael@0 974 return;
michael@0 975 case "collection_usage":
michael@0 976 case "collection_counts":
michael@0 977 case "quota":
michael@0 978 // TODO: implement additional info methods.
michael@0 979 this.respond(req, resp, 200, "OK", "TODO");
michael@0 980 return;
michael@0 981 default:
michael@0 982 // TODO
michael@0 983 this._log.warn("SyncServer: Unknown info operation " + rest);
michael@0 984 throw HTTP_404;
michael@0 985 }
michael@0 986 }
michael@0 987 }
michael@0 988 };
michael@0 989
michael@0 990 /**
michael@0 991 * Test helper.
michael@0 992 */
michael@0 993 function serverForUsers(users, contents, callback) {
michael@0 994 let server = new SyncServer(callback);
michael@0 995 for (let [user, pass] in Iterator(users)) {
michael@0 996 server.registerUser(user, pass);
michael@0 997 server.createContents(user, contents);
michael@0 998 }
michael@0 999 server.start();
michael@0 1000 return server;
michael@0 1001 }

mercurial