Add support for mutable/implicit headers for http.
authorTim Caswell <tim@creationix.com>
Thu, 10 Feb 2011 10:18:13 +0000 (02:18 -0800)
committerRyan Dahl <ry@tinyclouds.org>
Thu, 10 Feb 2011 10:31:41 +0000 (02:31 -0800)
This works for both ServerResponse and ClientRequest.
Adds three new methods as a couple properties to to OutgoingMessage objects.
Tests by Charlie Robbins.

Change-Id: Ib6f3829798e8f11dd2b6136e61df254f1564807e

doc/api/http.markdown
lib/http.js
test/simple/test-http-mutable-headers.js [new file with mode: 0644]

index 53476d7..6f68eeb 100644 (file)
@@ -261,10 +261,59 @@ Example:
 This method must only be called once on a message and it must
 be called before `response.end()` is called.
 
+If you call `response.write()` or `response.end()` before calling this, the
+implicit/mutable headers will be calculated and call this function for you.
+
+### response.statusCode
+
+When using implicit headers (not calling `response.writeHead()` explicitly), this property
+controlls the status code that will be send to the client when the headers get
+flushed.
+
+Example:
+
+    response.statusCode = 404;
+
+### response.setHeader(name, value)
+
+Sets a single header value for implicit headers.  If this header already exists
+in the to-be-sent headers, it's value will be replaced.  Use an array of strings
+here if you need to send multiple headers with the same name.
+
+Example:
+
+    response.setHeader("Content-Type", "text/html");
+
+or
+
+    response.setHeader("Set-Cookie", ["type=ninja", "language=javascript"]);
+
+
+### response.getHeader(name)
+
+Reads out a header that's already been queued but not sent to the client.  Note
+that the name is case insensitive.  This can only be called before headers get
+implicitly flushed.
+
+Example:
+
+    var contentType = response.getHeader('content-type');
+
+### response.removeHeader(name)
+
+Removes a header that's queued for implicit sending.
+
+Example:
+
+    response.removeHeader("Content-Encoding");
+
+
 ### response.write(chunk, encoding='utf8')
 
-This method must be called after `writeHead` was
-called. It sends a chunk of the response body. This method may
+If this method is called and `response.writeHead()` has not been called, it will
+switch to implicit header mode and flush the implicit headers.
+
+This sends a chunk of the response body. This method may
 be called multiple times to provide successive parts of the body.
 
 `chunk` can be a string or a buffer. If `chunk` is a string,
@@ -436,7 +485,10 @@ A queue of requests waiting to be sent to sockets.
 ## http.ClientRequest
 
 This object is created internally and returned from `http.request()`.  It
-represents an _in-progress_ request whose header has already been sent.
+represents an _in-progress_ request whose header has already been queued.  The 
+header is still mutable using the `setHeader(name, value)`, `getHeader(name)`,
+`removeHeader(name)` API.  The actual header will be sent along with the first
+data chunk or when closing the connection.
 
 To get the response, add a listener for `'response'` to the request object.
 `'response'` will be emitted from the request object when the response
index e373166..9506a8c 100644 (file)
@@ -303,6 +303,9 @@ function OutgoingMessage() {
   this._trailer = '';
 
   this.finished = false;
+  
+  this._headers = {};
+  this._headerNames = {};
 }
 util.inherits(OutgoingMessage, stream.Stream);
 
@@ -432,7 +435,6 @@ OutgoingMessage.prototype._storeHeader = function(firstLine, headers) {
 
     } else if (expectExpression.test(field)) {
       sentExpect = true;
-
     }
   }
 
@@ -495,9 +497,68 @@ OutgoingMessage.prototype._storeHeader = function(firstLine, headers) {
 };
 
 
+OutgoingMessage.prototype.setHeader = function(name, value) {
+  if (arguments.length < 2) {
+    throw new Error("`name` and `value` are required for setHeader().");
+  }
+
+  if (this._header) {
+    throw new Error("Can't set headers after they are sent.");
+  }
+
+  var key = name.toLowerCase();
+  this._headers[key] = value;
+  this._headerNames[key] = name;
+};
+
+
+OutgoingMessage.prototype.getHeader = function(name) {
+  if (arguments.length < 1) {
+    throw new Error("`name` is required for getHeader().");
+  }
+
+  if (this._header) {
+    throw new Error("Can't use mutable header APIs after sent.");
+  }
+
+  var key = name.toLowerCase();
+  return this._headers[key];
+};
+
+
+OutgoingMessage.prototype.removeHeader = function(name) {
+  if (arguments.length < 1) {
+    throw new Error("`name` is required for removeHeader().");
+  }
+
+  if (this._header) {
+    throw new Error("Can't remove headers after they are sent.");
+  }
+
+  var key = name.toLowerCase();
+  delete this._headers[key];
+  delete this._headerNames[key];
+};
+
+
+OutgoingMessage.prototype._renderHeaders = function() {
+  if (this._header) {
+    throw new Error("Can't render headers after they are sent to the client.");
+  }
+  var headers = {};
+  var keys = Object.keys(this._headers);
+  for (var i = 0, l = keys.length; i < l; i++) {
+    var key = keys[i];
+    headers[this._headerNames[key]] = this._headers[key];
+  }
+  return headers;
+};
+
+
+
 OutgoingMessage.prototype.write = function(chunk, encoding) {
   if (!this._header) {
-    throw new Error('You have to call writeHead() before write()');
+    this._implicitHeader();
   }
 
   if (!this._hasBody) {
@@ -557,6 +618,10 @@ OutgoingMessage.prototype.addTrailers = function(headers) {
 
 
 OutgoingMessage.prototype.end = function(data, encoding) {
+  if (!this._header) {
+    this._implicitHeader();
+  }
+
   var ret;
 
   var hot = this._headerSent === false &&
@@ -681,12 +746,16 @@ util.inherits(ServerResponse, OutgoingMessage);
 
 exports.ServerResponse = ServerResponse;
 
+ServerResponse.prototype.statusCode = 200;
 
 ServerResponse.prototype.writeContinue = function() {
   this._writeRaw('HTTP/1.1 100 Continue' + CRLF + CRLF, 'ascii');
   this._sent100 = true;
 };
 
+ServerResponse.prototype._implicitHeader = function() {
+  this.writeHead(this.statusCode, this._renderHeaders());
+};
 
 ServerResponse.prototype.writeHead = function(statusCode) {
   var reasonPhrase, headers, headerIndex;
@@ -742,12 +811,21 @@ function ClientRequest(options) {
   OutgoingMessage.call(this);
 
   var method = this.method = (options.method || 'GET').toUpperCase();
-  var path = options.path || '/';
-  var headers = options.headers || {};
-
-  // Host header set by default.
-  if (options.host && !(headers.host || headers.Host || headers.HOST)) {
-    headers.Host = options.host;
+  this.path = options.path || '/';
+
+  if (!Array.isArray(headers)) {
+    if (options.headers) {
+      var headers = options.headers;
+      var keys = Object.keys(headers);
+      for (var i = 0, l = keys.length; i < l; i++) {
+        var key = keys[i];
+        this.setHeader(key, headers[key]);
+      }
+    }
+    // Host header set by default.
+    if (options.host && !this.getHeader('host')) {
+      this.setHeader("Host", options.host);
+    }
   }
 
   this.shouldKeepAlive = false;
@@ -761,13 +839,21 @@ function ClientRequest(options) {
   // specified.
   this._last = true;
 
-  this._storeHeader(method + ' ' + path + ' HTTP/1.1\r\n', headers);
+  if (Array.isArray(headers)) {
+    this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n', headers);
+  } else if (this.getHeader('expect')) {
+    this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n', this._renderHeaders());
+  }
+
 }
 util.inherits(ClientRequest, OutgoingMessage);
 
 
 exports.ClientRequest = ClientRequest;
 
+ClientRequest.prototype._implicitHeader = function() {
+  this._storeHeader(this.method + ' ' + this.path + ' HTTP/1.1\r\n', this._renderHeaders());
+}
 
 ClientRequest.prototype.abort = function() {
   if (this._queue) {
diff --git a/test/simple/test-http-mutable-headers.js b/test/simple/test-http-mutable-headers.js
new file mode 100644 (file)
index 0000000..8b44cd0
--- /dev/null
@@ -0,0 +1,119 @@
+var common = require('../common');
+var assert = require('assert');
+var http = require('http');
+
+// Simple test of Node's HTTP Client mutable headers
+// OutgoingMessage.prototype.setHeader(name, value)
+// OutgoingMessage.prototype.getHeader(name)
+// OutgoingMessage.prototype.removeHeader(name, value)
+// ServerResponse.prototype.statusCode
+// <ClientRequest>.method
+// <ClientRequest>.path
+
+var testsComplete = 0;
+var test = 'headers';
+var content = 'hello world\n';
+var cookies = [
+  'session_token=; path=/; expires=Sun, 15-Sep-2030 13:48:52 GMT',
+  'prefers_open_id=; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT'
+];
+
+var s = http.createServer(function(req, res) {
+  switch (test) {
+    case 'headers':
+      assert.throws(function () { res.setHeader() });
+      assert.throws(function () { res.setHeader('someHeader') });
+      assert.throws(function () { res.getHeader() });
+      assert.throws(function () { res.removeHeader() });
+
+      res.setHeader('x-test-header', 'testing');
+      res.setHeader('X-TEST-HEADER2', 'testing');
+      res.setHeader('set-cookie', cookies);
+      res.setHeader('x-test-array-header', [1, 2, 3]);
+
+      var val1 = res.getHeader('x-test-header');
+      var val2 = res.getHeader('x-test-header2');
+      assert.equal(val1, 'testing');
+      assert.equal(val2, 'testing');
+
+      res.removeHeader('x-test-header2');
+      break;
+
+    case 'contentLength':
+      res.setHeader('content-length', content.length);
+      assert.equal(content.length, res.getHeader('Content-Length'));
+      break;
+
+    case 'transferEncoding':
+      res.setHeader('transfer-encoding', 'chunked');
+      assert.equal(res.getHeader('Transfer-Encoding'), 'chunked');
+      break;
+  }
+
+  res.statusCode = 201;
+  res.end(content);
+});
+
+s.listen(common.PORT, nextTest);
+
+
+function nextTest () {
+  if (test === 'end') {
+    return s.close();
+  }
+
+  var bufferedResponse = '';
+
+  http.get({ port: common.PORT }, function(response) {
+    console.log('TEST: ' + test);
+    console.log('STATUS: ' + response.statusCode);
+    console.log('HEADERS: ');
+    console.dir(response.headers);
+
+    switch (test) {
+      case 'headers':
+        assert.equal(response.statusCode, 201);
+        assert.equal(response.headers['x-test-header'],
+                     'testing');
+        assert.equal(response.headers['x-test-array-header'],
+                     [1,2,3].join(', '));
+        assert.deepEqual(cookies,
+                         response.headers['set-cookie']);
+        assert.equal(response.headers['x-test-header2'] !== undefined, false);
+        // Make the next request
+        test = 'contentLength';
+        console.log('foobar');
+        break;
+
+      case 'contentLength':
+        assert.equal(response.headers['content-length'], content.length);
+        test = 'transferEncoding';
+        break;
+
+      case 'transferEncoding':
+        assert.equal(response.headers['transfer-encoding'], 'chunked');
+        test = 'end';
+        break;
+
+      default:
+        throw Error("?");
+    }
+
+    response.setEncoding('utf8');
+    response.on('data', function(s) {
+      bufferedResponse += s;
+    });
+
+    response.on('end', function() {
+      assert.equal(content, bufferedResponse);
+      testsComplete++;
+      nextTest();
+    });
+  });
+}
+
+
+process.on('exit', function() {
+  assert.equal(3, testsComplete);
+});
+