Building Enterprise JavaScript Applications
上QQ阅读APP看书,第一时间看更新

Validating data type

We have completed our first scenario, so let's move on to our second and third scenarios. As a reminder, they are as follows:

  • If the client sends a POST request to /users with a payload that is not JSON, our API should respond with 415 Unsupported Media Type HTTP status code and a JSON object payload containing an appropriate error message.
  • If the client sends a POST request to /users with a malformed JSON payload, our API should respond with a 400 Bad Request HTTP status code and a JSON response payload containing an appropriate error message.

Start by adding the following scenario definition to the spec/cucumber/features/users/create/main.feature file:

  Scenario: Payload using Unsupported Media Type

If the client sends a POST request to /users with an payload that is
not JSON,
it should receive a response with a 415 Unsupported Media Type HTTP
status code.

When the client creates a POST request to /users
And attaches a generic non-JSON payload
And sends the request
Then our API should respond with a 415 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says 'The "Content-Type" header must always be "application/json"'

Scenario: Malformed JSON Payload

If the client sends a POST request to /users with an payload that is
malformed,
it should receive a response with a 400 Unsupported Media Type HTTP
status code.

When the client creates a POST request to /users
And attaches a generic malformed payload
And sends the request
Then our API should respond with a 400 HTTP status code
And the payload of the response should be a JSON object
And contains a message property which says "Payload should be in JSON format"

Notice that the first, third, and fifth steps are exactly the same as the ones in the previous scenario; therefore, Cucumber can re-use the step definition that we have already defined.

For the rest of the steps, however, we need to implement their corresponding step definitions. But since they are similar to the ones we've just defined, we can copy and paste them and make some small adjustments. Copy the following step definitions into spec/cucumber/steps/index.js:

When('attaches a generic non-JSON payload', function () {
this.request.send('<?xml version="1.0" encoding="UTF-8" ?><email>dan@danyll.com</email>');
this.request.set('Content-Type', 'text/xml');
});

When('attaches a generic malformed payload', function () {
this.request.send('{"email": "dan@danyll.com", name: }');
this.request.set('Content-Type', 'application/json');
});

Then('our API should respond with a 415 HTTP status code', function () {
assert.equal(this.response.statusCode, 415);
});

Then('contains a message property which says \'The "Content-Type" header must always be "application/json"\'', function () {
assert.equal(this.responsePayload.message, 'The "Content-Type" header must always be "application/json"');
});

Then('contains a message property which says "Payload should be in JSON format"', function () {
assert.equal(this.responsePayload.message, 'Payload should be in JSON format');
});

Now, when we run our tests again, the first three steps of the Payload using Unsupported Media Type scenario should pass:

$ yarn run test:e2e
.........F--

Failures:
1) Scenario: Payload using Unsupported Media Type
When the client creates a POST request to /users
And attaches a generic non-JSON payload
And sends the request
Then our API should respond with a 415 HTTP status code
AssertionError [ERR_ASSERTION]: 400 == 415
+ expected - actual
-400
+415
at World.<anonymous> (spec/cucumber/steps/index.js:35:10)
- And the payload of the response should be a JSON object
- And contains a message property which says "Payload should be in JSON format"

2 scenarios (1 failed, 1 passed)
12 steps (1 failed, 2 skipped, 9 passed)

The fourth step fails because, in our code, we are not specifically handling cases where the payload is a non-JSON or malformed object. Therefore, we must add some additional logic to check the Content-Type header and the actual contents of the request payload, which is much more involved than indiscriminately returning a 400 response:

import '@babel/polyfill';
import http from 'http';
function requestHandler(req, res) {
if (req.method === 'POST' && req.url === '/users') {
const payloadData = [];
req.on('data', (data) => {
payloadData.push(data);
});

req.on('end', () => {
if (payloadData.length === 0) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Payload should not be empty',
}));
return;
}
if (req.headers['content-type'] !== 'application/json') {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'The "Content-Type" header must always be "application/json"',
}));
return;
}
try {
const bodyString = Buffer.concat(payloadData).toString();
JSON.parse(bodyString);
} catch (e) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Payload should be in JSON format',
}));
}
});
} else {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello, World!');
  }
}
const server = http.createServer(requestHandler);
server.listen(8080);

For POST and PUT requests, the body payload can be quite large. So, instead of receiving the entire payload in one large chunk, it's better to consume it as a stream of smaller units. The request object, req, that is passed into the requestHandler function implements the ReadableStream interface. To extract the request body of POST and PUT requests, we must listen for the data and end events emitted from the stream.

Whenever a new piece of data is received by our server, the data event will be emitted. The parameter passed into the event listener for the data event is a type of Buffer, which is simply a small chunk of raw data. In our case, the data parameter represents a small chunk of our JSON request payload.

Then, when the stream has finished, the end event is emitted. It is here that we check whether the payload is empty, and if it is, we return a 400 error as we did before. But if it is not empty, we then check the Content-Type header to see if it is application/json; if not, we return a 415 error. Lastly, to check whether the JSON is well formed, we concatenate the buffer array to restore our original payload. Then, we try to parse the payload with JSON.parse. If the payload is able to be parsed, we don't do anything; if it is not, it means the payload is not valid JSON and we should return a 400 error, as specified in our step.

Lastly, we had to wrap the JSON.parse() call in a try/catch block because it'll throw an error if the payload is not a JSON-serializable string:

JSON.parse('<>'); // SyntaxError: Unexpected token < in JSON at position 0

We run the tests again; all tests should now pass, with one exception: somehow, the step And contains a message property which says 'The "Content-Type" header must always be "application/json"' is said to be undefined. But if we check our step definitions, we can certainly see it is defined. So what's happening?

This is because the forward slash character (/) has a special meaning in Gherkin. It specifies alternative text, which allows you to match either of the strings adjacent to the slash.

For example, the step definition pattern the client sends a GET/POST request would match both of the following steps:

  • the client sends a GET request
  • the client sends a POST request

Unfortunately, there is no way to escape the alternative text character. Instead, we must employ regular expressions to match this step definition pattern to its steps. This is as simple as replacing the containing single quotes with /^ and $/, and escaping the forward slash:

Then(/^contains a message property which says 'The "Content-Type" header must always be "application\/json"'$/, function () {
assert.equal(this.responsePayload.message, 'The "Content-Type" header must always be "application/json"');
});

Now, all our tests should pass:

$ yarn run test:e2e
..................

3 scenarios (3 passed)
18 steps (18 passed)

For consistency's sake, replace all other string patterns with regular expressions; run the tests again to ensure they're still passing.