Sphere Partners

A step-by-step guide for development of Node.js APIs

Date Published

Reading time

21 min
A step-by-step guide for development of Node.js APIs
In this article

Many articles describe the interaction between Node.js and Elasticsearch, but they often do not clearly explain how this interaction was achieved. To fill this gap, this article describes test-driven, step-by-step development of a simple RESTful API into an Elasticsearch in Node.js.

My main intention is to show Node.js developers how a RESTful API might be written using a TDD approach. Applying TDD practices makes the process much faster and results in a less error-prone API. Moreover, the whole application architecture becomes testable and therefore simpler and cleaner.

This article is divided into three main sections, with subsections and numbered steps to facilitate ease of use:

The

Proposed Architecture

section covers setup, installing the test framework, creating the first test, and defining the server.

The

Develop and Test Server

section describes preparing the server for content testing, as well as testing and adding support to the server.

The

Develop and Test Controller

section provides detail on developing the initial controller so that it supports standard REST actions (index, show, update, and destroy), and on refactoring the controller after this development.

Proposed Architecture

Let's start with the general API architecture. Some elements of MVC patterns can be successfully reused here. Strictly speaking, I am going to focus primarily on one element of that pattern:  the Controller. The architecture I am going to develop is represented by the gray rectangle in the schema below:

Set Up Architecture

1. Create an application directory:

bash
mkdir npm_api
cd npm_api

2.  Initialize the git repository:

git init

3. Initialize the npm application:

npm init

4. Accept all proposed default values.

Install Test Framework, Create First Test, Define Server

Mocha

’s

framework will work well for this project.

1.  Install Mocha:

npm install --save-dev mocha

2. Create an empty directory for tests:

mkdir test

3. Configure Mocha as your default test framework in the package.json:

text
"scripts": {
"test": "node_modules/.bin/mocha"
}

The above steps enable you to run tests with the default npm command:

npm test

At this point, you should see something like

No test files found

in the output. This is expected. We have not created any tests so far. That is the next step.

4. Install the supertest library for request testing:

npm install --save-dev supertest

5. Create the first test:

test/server.js:

javascript
describe('server', () => {
const request = supertest(server);
//checks that server returns success response when 'GET /posts' is performed
//response content is not verified here yet. It will be done in the next
//iteration
describe('GET /posts', () =>
it('responds with OK', () =>
request
.get('/posts')
.expect(200)
)
);
});

6. Run the first test and see this expected error message:

Error: Cannot find module '../app/server'

7. Configure server instance:

I will use the Restify package for building the web API:

npm install --save-dev restify

Here is the basic trivial server just to make our only test green:

javascript
const restify = require('restify');
const server = restify.createServer();
//Server always responds with the empty object for now. Content
//is not tested yet. Just server availability is tested.
server.get('/posts', (req, res, next) =>
res.send({})
);
module.exports = server;

You can see that this is a very trivial server definition. It has only one action defined,

GET /posts

, which always returns an empty object. If all steps have been done properly, then

npm test

will output:

text
server
GET /posts
✓ responds with OK
1 passing (32ms)

Now we have a server that responds to

GET /posts

properly. It is the perfect time to make our first manual integration test to check that all the pieces we have created fit together properly.

8. Create script that runs the server:

./start.js:

javascript
const server = require('./app/server');
server.listen(8080, () =>
console.log('%s listening at %s', server.name, server.url)
);

9. Update package.json to define it as an application start script:

package.json:

text
"script": {
"test": "node_modules/.bin/mocha",
"start": "node start.js"
}

10. Run the server itself:

npm start

The server should be running and ready to accept requests on the port 8080:

text
> node_api@1.0.0 start /projects/node_api
> node start.js
restify listening at https://[::]:8080

It will always respond with an empty object as expected:

curl -XGET https://localhost:8080/posts {}%

Now we have an API server that is ready to respond to our requests.

Develop and Test Server

According to our proposed architecture, the server should redirect all requests to the controller,

PostsController

in our case. It will be responsible for handling requests, gathering all necessary data, and rendering results.

Prepare Server for Content Testing

1. Extract the controller from the current server implementation:

app/controllers/posts.js:

text
module.exports = class {
index() {
//even at this early phase we can assume that controller will return
//some kind of promise because it will make a request to ES that
//are asynchronous
return new Promise((resolve, reject) =>
resolve({})
);
}
};

This is the simplest possible controller version.

2. Modify our server to use the controller:

javascript
const restify = require('restify');
const server = restify.createServer();
const PostsController = require('./controllers/posts.js');
const posts = new PostsController();
server.get('/posts', (req, res, next) =>
posts.index().then((result) =>
res.send(200, result)
)
);
module.exports = server;

Now we have a controller instance that has been created INSIDE the server. But to be able to write isolated unit tests of the server, we need some way to pass a fake controller to the server and then ensure that all methods on that fake controller are within proper parameters.

3. Modify the server to be able to accept controller instance:

app/server.js:

javascript
const restify = require('restify');
//PostsController intance must be created and passed from outside
module.exports = (posts) => {
const server = restify.createServer();
server.get('/posts', (req, res, next) =>
posts.index().then((result) =>
//we are not testing content here just server availability
res.send(200)
)
);
return server;
};

As a result of this step, we have defined the server factory rather than the server definition. This server factory created a server instance based on the controller parameters that it accepts.

Test Server

1. Modify the server test and specify fake controller instance:

test/server.js:

javascript
const supertest = require('supertest');
const server = require('../app/server');
describe('server', () => {
//PostsController stub
const posts = {};
const request = supertest(server(posts));
describe('GET /posts', () => {
//test function that is called by the server instance
before(() => {
posts.index = () =>
new Promise((resolve, reject) =>
resolve({})
);
});
it('responds with OK', () =>
request
.get('/posts')
.expect(200)
);
});
});

You can see above that posts is a simple, plain object used as a controller stub object that has only one method,

index

, defined on it. That makes it possible to control both the results that are returned to the server from the controller, and the params that are passed from the server to the controller. (More detail on this below.) If the steps have been done properly, all tests will be green now.

2. Run test:

npm test

3. Modify our start script and pass real PostsController instance to the server instance:

./start.js:

javascript
const serverFactory = require('./app/server');
const PostsController = require('./app/controllers/posts');
const posts = new PostsController();
const server = serverFactory(posts);
server.listen(8080, () =>
console.log('%s listening at %s', server.name, server.url)
);

4. Make a simple integration test to check that nothing is broken:

Run server:

npm start

Send a test request to the server:

curl -XGET https://localhost:8080/posts {}%

An empty object is returned, which is exactly what was expected. Now we have everything prepared for content testing, specifically making sure that the server properly serializes data that is returned from the controller and responds with that data.

5. Update test so it becomes red:

test/server.js:

typescript
const _ = require('lodash');
const supertest = require('supertest');
const server = require('../app/server');
describe('server', () => {
const posts = {};
const request = supertest(server(posts));
describe('GET /posts', () => {
//test data that is returned by the posts controller stub
const data = [{id: 1, author: 'Mr. Smith', content: 'Now GET /posts works'}];
//test method now returns test data
before(() => {
posts.index = () =>
new Promise((resolve, reject) =>
resolve(data)
);
});
//checks that server responds with the proper HTTP code and exactly with the
//same data it received from the controller
it('responds with OK', () =>
request
.get('/posts')
.expect(data)
.expect(200)
);
});
});

6. Run npm test and see an error:

Error: expected [ { id: 1, author: 'Mr. Smith', content: 'Now GET /posts works' } ] response body, got {}

It looks like the server does not return post data. The server needs to be modified.

7. Update server:

javascript
const restify = require('restify');
module.exports = (posts) => {
const server = restify.createServer();
server.get('/posts', (req, res, next) =>
posts.index().then((result) =>
//now we returns not only code but content also
res.send(200, result)
)
);
return server;
};

If you run

npm test

now, you should see that all tests are green. Nice!

Add Support to Create New Post Instance

Now it's time to add support for one more action to our server,

POST /posts

, that will create a new post instance.

1. Create test:

test/server.js:

typescript
describe('POST /posts', () => {
//data that is sent to the server
const data = [{ author: 'Mr. Rogers', content: 'Now POST /posts works' }];
before(() => {
//so we expect server to return attributes fo the new post
posts.create = (attrs) =>
new Promise((resolve, reject) =>
resolve(_.merge({ id: 2 }, attrs))
);
});
it('responds with Created and returns content of the newly create post', () =>
request
.post('/posts')
.send({ post: data })
.expect(_.merge({ id: 2 }, data))
.expect(201)
);
});

2. Run npm test and see the following error:

text
Error: expected { '0': { author: 'Mr. Rogers', content: 'Now POST /posts works' },
id: 2 } response body, got { code: 'MethodNotAllowedError',
message: 'POST is not allowed' }

This result is actually expected.

POST /posts

must be defined on the server to fix the test.

3. Define POST /posts on the server:

app/server.js:

text
//So here we just pass post attributes to the controller and returns back
//its result
server.post('/posts', (req, res, next) =>
posts.create(req.params.post).then((result) =>
res.send(201, result)
)
);

If we run npm test at this point, we will still see an error:

Error: expected { '0': { author: 'Mr. Rogers', content: 'Now POST /posts works' }, id: 2 } response body, got { id: 2 }

It looks like the params we sent to the server were not parsed properly.

4. Plug body parser into the server:

app/server.js:

javascript
const restify = require('restify');
module.exports = (posts) => {
const server = restify.createServer();
//we need that parser to work with params that are defined in
//the request body
server.use(restify.bodyParser());
server.get('/posts', (req, res, next) =>
posts.index().then((result) =>
res.send(200, result)
)
);
server.post('/posts', (req, res, next) =>
posts.create(req.params.post).then((result) =>
res.send(201, result)
)
);
return server;
};

5. Run npm test again. Everything should be fine.

The next action I am going to add is

GET /posts/:id

. This action is different from the previous two. It's tricky in that the API consumer might specify nonexistent post identities that the server must handle gracefully. For now, let's implement a simple action version that does not handle situations when posts do not exist.

6. Create test as usual:

test/server.js

typescript
describe('GET /posts/:id', () => {
//data that is returned from the controller stub
const data = [{ author: 'Mr. Williams', content: 'Now GET /posts/:id works' }];
//show action stub. it merges specified id with the predefined data
//to imitate real controller behaviour at one hand and
//check that proper id was passed to the controller at another one
before(() => {
posts.show = (id) =>
new Promise((resolve, reject) =>
resolve(_.merge({ id: id }, data))
);
});
//checks that server just pass id to the controller and
//returns its result.
it('responds with OK and returns content of the post', () =>
request
.get('/posts/3')
.send(data)
.expect(_.merge({ id: 3 }, data))
.expect(200)
);
});

7. Run npm test and get an error:

Error: expected { '0': { author: 'Mr. Williams', content: 'Now GET /posts/:id works' }, id: 3 } response body, got { code: 'ResourceNotFound', message: '/posts/3 does not exist' }

The resource is not found. We need to define the action on the server.

8. Define action on the server:

app/server.js:

text
server.get('/posts/:id', (req, res, next) =>
posts.show(req.params.id).then((result) =>
res.send(200, result)
)
);

9. Run test. Now everything should be green!

But what about cases when there is no post with the specified

ID

? The server should obviously return NotFound (404) HTTP status in this case. Let’s continue by addressing this situation.

10. Add test:

test/server.js:

text
context('when there is no post with the specified id', () => {
//here its assumed that controller will return rejected promice
//when post with the specified id is not found
before(() => {
posts.show = (id) =>
new Promise((resolve, reject) =>
reject(id)
);
});
//test that server responds with 404 code if post was not found
it('responds with NotFound', () =>
request
.get('/posts/3')
.send(data)
.expect(404)
);
});

11. Run npm test again and get an error:

Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

This error occurred because the promise in the controller stub was rejected. We need to modify the server to handle this circumstance.

12. Correct error:

app/server.js:

text
server.post('/posts', (req, res, next) =>
posts.show(req.params.id).then((result) =>
res.send(200, result)
).catch(() => res.send(404))
);

13: Run tests again. They should all be green.

The next action in line involves update:

POST /posts/:id

. Similar to the previous action, we must develop a clear path first, assuming that the correct post

ID

is specified. We will consider situations when an invalid

ID

is specified later.

14. Create test first as usual:

text
describe('POST /posts/:id', () => {
//data that is sent to the server
var data = [{ author: 'Mr. Williams', content: 'Now POST /posts/:id works' }];
//test actions returns specified attributes merged with the
//specified identified so it's possible to control correctness
//of the parameters that were passed to the controller stub
before(() => {
posts.update = (id, attrs) =>
new Promise((resolve, reject) =>
resolve(_.merge({ id: id }, attrs))
);
});
//and in the test below response data and status are verified
it('responds with Created and returns content of the updated post', () =>
request
.post('/posts/4')
.send({ post: data })
.expect(_.merge({ id: 4 }, data))
.expect(200)
);
});

15. Run test and see an expected error:

text
Error: expected { '0': { author: 'Mr. Williams', content: 'Now POST /posts/:id works' },
id: 4 } response body, got { code: 'MethodNotAllowedError',
message: 'POST is not allowed' }

16. Define missing method:

app/server.js:

text
server.post('/posts/:id', (req, res, next) =>
posts.update(req.params.id, req.params.post).then((result) =>
res.send(200, result)
)
);

Now we have an updated server action that is capable of handling existing resources. It's time to address cases when the identifier of nonexistent posts is specified.

17. Create the test:

test/server.js:

text
context('when there is no post with the specified id', () => {
before(() => {
posts.update = (id) =>
new Promise((resolve, reject) =>
reject(id)
);
});
it('responds with 404 HTTP response', () =>
request
.post('/posts/3')
.send({ post: data })
.expect(404)
);
});

18. Run npm test and get an error:

Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

This result is expected. Rejected promises have not been addressed yet.

19. Add error handling to the server action:

text
server.post('/posts/:id', (req, res, next) =>
posts.update(req.params.id, req.params.post).then((result) =>
res.send(200, result)
).catch(() => res.send(404))
);

20. Run tests again. They are green!

At this point, only one action has not been implemented:

DELETE /posts/:id

. Now it's time to fill this gap. Here is the code for the action test:

text
describe('DELETE /posts/:id', () => {
//imitate action that always returns id of the deleted post
before(() =>
posts.destroy = (id) =>
new Promise((resolve, reject) =>
resolve({ id: id })
)
);
//checks that server returns deleted post identified
it('responds with the id of the deleted post', () =>
request
.delete('/posts/5')
.expect({ id: 5 })
);
});

21. Run action test. Get an error.

22. Define action on the server:

text
server.del('/posts/:id', (req, res, next) =>
posts.destroy(req.params.id).then((result) =>
res.send(200, { id: req.params.id })
)
);

23. Run tests again. Green!

Now let's handle cases when there is no post with the specified

ID

. Here is the test code: test/server.js:

text
context('when there is no post with the specified id', () => {
before(() =>
posts.destroy = (id) =>
new Promise((resolve, reject) =>
reject(id)
)
);
it('responds with NotFound', () =>
request
.delete('/posts/5')
.expect(404)
);
});

24. Run the test. Get timeout error.

25. Update server:

app/server.js:

text
server.del('/posts/:id', (req, res, next) =>
posts.destroy(req.params.id).then((result) =>
res.send(200, { id: req.params.id })
).catch(() => res.send(404))
);

Everything is green. Now we have a fully workable server. It properly redirects request data to the controller and writes serialized results to the response. It does not yet access ES instances and returns dummy data. This function is addressed below.

Develop and Test Controller

This section concerns specifically the

PostsController

. It will work with an ES client. We can assume that the ES client is well-tested so we do not have to test it ourselves. Only the methods of the controller in isolation should be tested. To do so, we need to pass the client stub to the controller instance to be able to verify that the correct methods were called on the stub, and the returned data was properly handled.

Prepare Controller for Testing

1. Update PostsController definition so it accepts client instance from outside:

app/controllers/posts.js:

text
module.exports = class {
constructor(client) {
this.client = client;
}
index() {
//controller still always returns empty object
return new Promise((resolve, reject) =>
resolve({})
);
}
};

2. Install another test library:

npm install should --save-dev

3. Introduce the ES client stub into the posts controller test:

test/controllers/posts.js:

javascript
var PostsController = require('../../app/controllers/posts');
describe('PostsController', function() {
var client = {};
var posts = new PostsController(client);
});

4. Run tests. Everything should be green.

Now we have prepared the basis for

PostsController

development.

Test Controller

1. Write our first test - index action.

Here is the test code:

text
describe('index', () => {
before(() =>
client.search = () =>
new Promise((resolve, reject) =>
resolve({
"took": 27,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"failed": 0
},
"hits": {
"total": 1,
"max_score": 1,
"hits": [
{
"_index": 'index',
"_type": 'type',
"_id": "AVhMJLOujQMgnw8euuFI",
"_score": 1,
"_source": {
"text": "Now PostController index works!",
"author": "Mr.Smith"
}
}
]
}
})
)
);
it('parses and returns post data', () =>
posts.index().then((result) =>
result.should.deepEqual([{
id: "AVhMJLOujQMgnw8euuFI",
author: "Mr.Smith",
text: "Now PostController index works!"
}])
)
);
});

We see that the possible ES response is imitated here.

2. Run the first test and see a failure. Our current PostsController always returns an empty object.

3. Correct failure:

typescript
const _ = require('lodash');
module.exports = class {
constructor(client) {
this.client = client;
}
//index returns list of posts attributes merged with corresponding
//identifiers
index() {
return this.client
.search()
.then((res) =>
_.map(res.hits.hits, (hit) =>
_.merge(hit._source, { id: hit._id })
)
);
}
};

4. Run test again. Green!

Test Parameters

We just tested that

PostsController

parsed the ES result properly, but we also need to test that it passes correct params to the client. Two params of the ES client method need to be specified:

search: index

and

type

.

1. Add params to the PostsController constructor:

app/controllers/posts.js:

text
//index and type names should be specified outside now
constructor(client, indexName, type) {
this.client = client;
this.indexName = indexName;
this.type = type;
}

2. Specify test params in the PostsController specs:

test/controller/posts.js:

javascript
describe('PostsController', () => {
const client = {};
//'index' and 'type' are some virtual index and type names
const posts = new PostsController(client, 'index', 'type');
...
}

3. Run test again. Green! Nothing is broken.

4. Verify that we specified those params properly in test when client.search method is called.

I am going to use

sinon

for spying on method calls:

npm install sinon --save-dev

I am using should-sinon for should - like asserts:

npm install should-sinon --save-dev

Specify these in the controller test: test/controllers/posts.js:

var sinon = require('sinon'); require('should-sinon');

Now we can write a params verification test: test/controllers/posts.js:

javascript
it('specifies proper index and type while searching', () => {
const spy = sinon.spy(client, 'search');
//It's expected below that method search() is called once with
//proper index name and object type as paramters.
return posts.index().then(() => {
spy.should.be.calledOnce();
spy.should.be.calledWith({
index: 'index',
type: 'type'
});
});
});

5. Run npm test. See the following failure:

text
expected 'search' to be called with arguments { index: "index", type: "type" }
search() => [Promise] { } at PostsController.index (/projects/node_api/app/controllers/posts.js:14:22)
expected false to be true

6. Update the controller:

app/controllers/posts.js:

text
index() {
//pass index name and object type to the controller
return this.client.search({
index: this.indexName,
type: this.type
})
.then((res) =>
_.map(res.hits.hits, (hit) =>
_.merge(hit._source, { id: hit._id })
)
);
}

The tests should be green.

Create and Run Manual Integration Tests

Now is a good time to run simple manual integration tests to be sure that all the pieces we have created correspond properly. Each piece is covered by its own unit test. We cannot be sure they all interact correctly without integration testing. It would be best to create manual integration tests for this purpose, but that is beyond the scope of this article. To complete the following steps, you should have ES service installed locally and be running on default 9200 port.

1. Create index ( 'node_api' ):

curl -XPOST localhost:9200/node_api

Expected output:

{"acknowledged":true}%

2. Create post example:

curl -XPOST localhost:9200/node_api/posts -d '{ "author": "Mr. Smith", "content": "Now GET /posts works!" }'

Expected output:

{"_index":"node_api","_type":"posts","_id":"AViW9F1lhQ3AxSLOwi2k","_version":1,"created":true}%

3. Install elasticsearch npm package:

npm install elasticsearch --save-dev

4. Create ES client instance:

./app/client.js:

javascript
const elasticsearch = require('elasticsearch');
module.exports = new elasticsearch.Client({
host: 'localhost:9200'
});

5. Update start script with the real index and type names:

./start.js:

javascript
const client = require('./app/client');
const serverFactory = require('./app/server');
const PostsController = require('./app/controllers/posts');
const posts = new PostsController(client, 'node_api', 'posts');
const server = serverFactory(posts);
server.listen(8080, () =>
console.log('%s listening at %s', server.name, server.url)
);

6. Run server:

npm start

7. Make test request:

curl -XGET https://localhost:8080/posts/1

If all steps have been done properly, you should see something like this in the output:

[{"author":"Mr. Smith","content":"Now GET /posts works!","id":"AViW9F1lhQ3AxSLOwi2k"}]%

Our integration test was successful. We can continue adding methods to the controller, knowing that the server has been configured properly and calls proper controller methods.

Implement Post Indexing in ES

1. Create test code:

typescript
describe('create', () => {
const attrs = { author: 'Mr. Rogers', text: "Now PostController create works!" };
before(() => {
client.index = () =>
new Promise((resolve, reject) =>
resolve({
"_index": 'index',
"_type": "type",
"_id": "AViXYdnZxmF-_Ui11JAF",
"_version": 1,
"created": true
})
);
});
it('parses and returns post data', () =>
posts.create(attrs).then((result) =>
result.should.deepEqual(_.merge({ id: "AViXYdnZxmF-_Ui11JAF" }, attrs))
)
);
it('specifies proper index, type and body', () => {
const spy = sinon.spy(client, 'index');
return posts.create(attrs).then(() => {
spy.should.be.calledOnce();
spy.should.be.calledWith({
index: 'index',
type: 'type',
body: attrs
});
});
});
});

Two tests are defined above. In real situations, these tests would consist of two interactions. I joined them into one interaction here for simplicity’s sake.

2. Run tests and see errors

3. Add indexing support to the controller:

sql
create(attrs) {
return this.client.index({
index: this.indexName,
type: this.type,
body: attrs
})
.then((res) =>
_.merge({ id: res._id }, attrs)
);
}

The tests will be green if steps have been done properly.

Implement “Show” Action

The next controller action is show. Similar to

GET /show/:id

, this controller action should handle situations when a post with a specified identifier does not exist. We will take care of that later. Now, let's start from the simplified action version, assuming that only a correct identifier can be specified.

1. Create test first as usual:

test/controllers/posts.js:

typescript
describe('show', () => {
const id = "AVhMJLOujQMgnw8euuFI";
const attrs = [{ author: 'Mr. Williams', content: 'Now PostsController show works!' }];
before(() =>
client.get = () =>
new Promise((resolve, reject) =>
resolve({
"_index": 'index',
"_type": 'post',
"_id": id,
"_version": 1,
"found": true,
"_source": attrs
})
)
);
it('parses int returns post data', () =>
posts.show(id).then((result) =>
result.should.deepEqual(_.merge({ id: id }, attrs))
)
);
it('specifies proper index, type and id', () => {
const spy = sinon.spy(client, 'get');
return posts.show(1).then(() => {
spy.should.be.calledOnce();
spy.should.be.calledWith({
index: 'index',
type: 'type',
id: 1
});
});
});
});

2. Run npm test, get error.

If you run

npm test

now, you will see an error because method

show

has not been defined on the

PostsController

.

3. Correct error:

app/controllers/posts.js:

text
show(id) {
return this.client.get({
index: this.indexName,
type: this.type,
id: id
})
.then((res) =>
_.merge({ id: res._id }, res._source)
);
}

4. Run npm test again. All tests should be green.

The

PostsController

is now able to find the post and return its content. But if the identifier of a nonexistent post was specified, the controller will fail. We need to handle this exceptional situation properly.

5. Create test for nonexistent post identifier:

test/controllers/posts.js:

text
context('when there is no post with the specified id', () => {
before(() =>
client.get = () => {
return new Promise((resolve, reject) =>
resolve({
"_index": 'index',
"_type": 'post',
"_id": id,
"found": false
})
);
}
);
it('returns rejected promise with the non existing post id', () =>
posts.show(id).catch((result) =>
result.should.equal(id)
)
);
});

6. Run test and get an error.

7. Update controller:

app/controllers/posts.js:

text
show(id) {
return this.client.get({
index: this.indexName,
type: this.type,
id: id
})
.then((res) =>
new Promise((resolve, reject) => {
if (res.found) {
return resolve(_.merge({ id: res._id }, res._source));
}
reject(id);
})
);
}

Now we have fully implemented show action.

Enable “Update” Function

The next action concerns

update

. As in the case of

show

action, we need to address situations when a nonexistent post identifier is passed to the action. Also similar to the

show

, we will handle that later and start from a simplified version.

1. Create the test code:

typescript
describe('update', () => {
const id = "AVhMJLOujQMgnw8euuFI";
const attrs = [{ author: 'Mr. Williams', content: 'Now PostsController show works!' }];
before(() =>
client.update = () =>
new Promise((resolve, reject) =>
resolve({
"_index": "index",
"_type": "type",
"_id": id,
"_version": 4
})
)
);
it('parses and returns post data', () =>
posts.update(id, attrs).then((result) =>
result.should.deepEqual(_.merge({ id: id }, attrs))
)
);
it('specifies proper index, type, id and attrs', () => {
const spy = sinon.spy(client, 'update');
return posts.update(id, attrs).then(() => {
spy.should.be.calledOnce();
spy.should.be.calledWith({
index: 'index',
type: 'type',
id: id,
doc: attrs
});
});
});
});

2. Run npm test and get these errors:

text
1) PostsController update parses and returns post data:
TypeError: posts.update is not a function
at Context.it (test/controllers/posts.js:178:13)
2) PostsController update specifies proper index, type, id and attrs:
TypeError: posts.update is not a function
at Context.it (test/controllers/posts.js:186:20)

So,

update

is not yet a function. Let’s fix this.

3. Define update method:

app/controllers/posts.js:

sql
update(id, attrs) {
return this.client.update({
index: this.indexName,
type: this.type,
id: id,
doc: attrs
})
.then((res) =>
_.merge({ id: res._id }, attrs)
);
}

The errors should be corrected. Now it's time to address situations when an identifier of a nonexistent resource is specified.

4. Create test for nonexistent ID specification:

text
context('when there is no post with the specified id', () => {
before(() =>
client.update = () => {
return new Promise((resolve, reject) =>
resolve({
"error": "DocumentMissingException[[node_api][3] [posts][AVhMJLOujQMgnw8euuFI]: document missing]",
"status": 404
})
);
}
);
it('returns rejected promise with the non existing post id', () =>
posts.update(id, attrs).catch((result) =>
result.should.equal(id)
)
);
});

5. Run test and see a failure.

6. Update the definition of the method update:

sql
update(id, attrs) {
return this.client.update({
index: this.indexName,
type: this.type,
id: id,
doc: attrs
})
.then((res) =>
new Promise((resolve, reject) => {
if (res._id) {
return resolve(_.merge({ id: res._id }, attrs));
}
reject(id);
})
);
}

7. Run tests. They should be green.

Enable “Destroy” Function

This is the final

REST

action to be implemented. As with previous actions, we need to handle situations when the

ID

of the non-existing resource is specified as a parameter of the action. Also following our previous methods, we will implement a simple action version first and handle non-existing sources second.

1. Create the test code:

test/controllers/posts.js:

typescript
describe('destroy', () => {
const id = "AVhMJLOujQMgnw8euuFI";
before(() =>
client.delete = () =>
new Promise((resolve, reject) =>
resolve({
"found": true,
"_index": "index",
"_type": "type",
"_id": id,
"_version": 6
})
)
);
it('parses and returns post data', () =>
posts.destroy(id).then((result) =>
result.should.equal(id)
)
);
it('specifies proper index, type and id', () => {
const spy = sinon.spy(client, 'delete');
return posts.destroy(id).then(() => {
spy.should.be.calledOnce();
spy.should.be.calledWith({
index: 'index',
type: 'type',
id: id
});
});
});
});

2. Run npm test. See these errors:

text
1) PostsController destroy parses and returns post data:
TypeError: posts.destroy is not a function
at Context.it (test/controllers/posts.js:234:13)
2) PostsController destroy specifies proper index, type and id:
TypeError: posts.destroy is not a function
at Context.it (test/controllers/posts.js:242:20)

3. Define destroy action:

text
destroy(id) {
return this.client.delete({
index: this.indexName,
type: this.type,
id: id
})
.then((res) => id);
}
4. Run test; It should be green.

We are now able to destroy the post with the specified identifier.

5. Create test code for nonexistent resource:

text
context('when there is no post with the specified id', () => {
//ES returns "found" equals false if is not able to find resource
//with the specified identifier.
before(() =>
client.delete = () =>
new Promise((resolve, reject) =>
resolve({
"found": false,
"_index": "index",
"_type": "type",
"_id": id,
"_version": 6
})
)
);
//checks that promise is rejected
it('returns rejected promise with the non existing post id', () =>
posts.destroy(id).catch((result) =>
result.should.equal(id)
)
);
});

6. Check functionality:

text
destroy(id) {
return this.client.delete({
index: this.indexName,
type: this.type,
id: id
})
.then((res) =>
new Promise((resolve, reject) => {
if (res.found) {
return resolve(id);
}
//reject with the post identifier.
reject(id);
})
);
}

7. Run tests. They should be green.

Now we have completed all API functionality. Posts can be created, deleted, updated, and listed. The next section covers clean up.

Refactor Controller

Refactoring is a safe and easy operation in our case because the tests cover it. Right now, we have a repetitive pattern in our

app/controllers/posts.js:

text
{
index: this.indexName,
type: this.type
...
}

Let's try to DRY it and clean it up.

1. Extract all such patterns into a dedicated class:

app/lib/resource.js:

sql
const _ = require('lodash');
module.exports = class {
constructor(client, indexName, type) {
this.client = client;
this.baseParams = { index: indexName, type: type };
}
search() {
return this.client.search(this.baseParams);
}
create(attrs) {
return this.client.index(_.merge({ body: attrs }, this.baseParams));
}
get(id) {
return this.client.get(_.merge({ id: id }, this.baseParams));
}
update(id, attrs) {
return this.client.update(_.merge({ id: id, doc: attrs }, this.baseParams));
}
delete(id) {
return this.client.delete(_.merge({ id: id }, this.baseParams));
}
};

Our controller has now been simplified a bit:

sql
const _ = require('lodash');
const Resource = require('../lib/resource');
module.exports = class {
constructor(client, indexName, type) {
this.resource = new Resource(client, indexName, type);
}
index() {
return this.resource.search()
.then((res) =>
_.map(res.hits.hits, (hit) =>
_.merge(hit._source, { id: hit._id })
)
);
}
create(attrs) {
return this.resource.create(attrs)
.then((res) =>
_.merge({ id: res._id }, attrs)
);
}
show(id) {
return this.resource.get(id)
.then((res) =>
new Promise((resolve, reject) => {
if (res.found) {
return resolve(_.merge({ id: res._id }, res._source));
}
reject(id);
})
);
}
update(id, attrs) {
return this.resource.update(id, attrs)
.then((res) =>
new Promise((resolve, reject) => {
if (res._id) {
return resolve(_.merge({ id: res._id }, attrs));
}
reject(id);
})
);
}
destroy(id) {
return this.resource.delete(id)
.then((res) =>
new Promise((resolve, reject) => {
if (res.found) {
return resolve(id);
}
reject(id);
})
);
}
};

We have extracted all our interactions into a special, dedicated Resource class. But results parsing is still in the controller. Let’s address this.

2. Extract results parsing into a special Parser class:

app/lib/parser.js:

typescript
const _ = require('lodash');
module.exports = class {
parseSearchResult(res) {
return _.map(res.hits.hits, (hit) =>
_.merge(hit._source, { id: hit._id })
);
}
parseCreateResult(attrs) {
return (res) => _.merge({ id: res._id }, attrs);
}
parseGetResult(res) {
return new Promise((resolve, reject) => {
if (res.found) {
return resolve(_.merge({ id: res._id }, res._source));
}
reject(res._id);
});
}
parseUpdateResult(id, attrs) {
return (res) =>
new Promise((resolve, reject) => {
if (res._id) {
return resolve(_.merge({ id: res._id }, attrs));
}
reject(id);
});
}
parseDeleteResult(id) {
return (res) =>
new Promise((resolve, reject) => {
if (res.found) {
return resolve(id);
}
reject(id);
});
}
};

Here is the result:

sql
const Resource = require('../lib/resource');
const Parser = require('../lib/parser');
module.exports = class {
constructor(client, indexName, type) {
this.resource = new Resource(client, indexName, type);
this.parser = new Parser();
}
index() {
return this.resource.search().then(this.parser.parseSearchResult);
}
create(attrs) {
return this.resource.create(attrs).then(this.parser.parseCreateResult(attrs));
}
show(id) {
return this.resource.get(id).then(this.parser.parseGetResult);
}
update(id, attrs) {
return this.resource.update(id, attrs).then(this.parser.parseUpdateResult(id, attrs));
}
destroy(id) {
return this.resource.delete(id).then(this.parser.parseDeleteResult(id));
}
};

Our controller looks much better now!

Summary

We have now completed a Node.js API for an ES by following step-by-step instructions led by testing. The result is a relatively simple API with reliable test coverage. Each of the steps described is trivial and might be done quite easily without any debugging efforts. The TDD approach I have detailed results in better, cleaner code when developing Node.js applications. Once you try it, you will see it is also faster than creating code before the test.

More to read

How to Choose an AI Software Development Company (And What to Watch Out For) — hero image
Consulting & Advisory,  Tech Executive Advisory,  Data & AI,  IT Strategy Consulting,  Software Development,  ChatGPT,  Trends

Not all AI software development companies are equal. Learn what separates firms that truly build with AI from those that just use the word. Includes real questions to ask and red flags to avoid.

Exploring the Integration of AI in Software Development: A Full-Stack Developer's Perspective
Software Development,  Data & AI,  ChatGPT

Dive into Sphere's full-stack developer journey with AI – from tackling code with GitHub Copilot to unleashing problem-solving insights with ChatGPT. Explore the potential of AI in software development projects: which tools are truly handy, how many hours can you save, and what's the next big thing? Pavel Korchak shares his insights.

We'd love to hear from you!

Please provide your contact details, and our team will get back to you promptly.