Welcome back to Code in Action, the series where we build practical backend projects, step by step.
In this tutorial, we're going to build a simplified version of Strawpoll — the popular online service that lets users create quick polls, share them with others, and collect votes in real time.
Our API will include 3 endpoints:
- A GET
/poll/:id
for retrieving polls. - A POST
/poll/create
for creating polls. - A POST
/poll/vote
for voting in polls.
Along the way, we'll implement an in-memory SQL-like database to store our data, and a schema validator middleware to ensure the data of every incoming HTTP request is clean and predictable.
Ready? Let's build!
Step 1: Set Up the Project
Let's create and enter the project's directory named strawpoll-lite
.
mkdir strawpoll-lite
cd strawpoll-lite
Create the package.json
file of the project.
npm init -y
And install both:
The
express
package for creating web servers.The
joi
package for creating object validation schemas.
npm install express joi
Step 2: Create the Database Object
📚 A poll is a simple survey where users can choose one option from a list of possible choices, and each choice submitted by a user is recorded as a vote.
With that in mind, let's create a new file named database.js
, that exports an object literal as an in-memory database, that will store the polls, their choices, and the user votes.
// File: database.js
module.exports = {
_indexes: {
polls: 1,
choices: 1,
votes: 1,
},
tables: {
polls: [],
choices: [],
votes: [],
},
};
Where:
-
tables
is an object used to store poll, choice, and vote objects, each identified by an index, which is a unique, positive integer. -
_indexes
is an object used to keep track of the indexes in each table.
If you aren't familiar with SQL-like databases, a table is a related collection of records (or entries), where each record is individually identified by a unique property — usually an integer called an index.
This index is often defined as self-incrementing, which means that every time a new record is added to a table, its index value is incremented by 1.
To mimic this behavior, we'll manually use and update the corresponding
_indexes
property of each record type, every time we add a new one into its correspondingtables
object.
Here's for example what the database object would look like if it contained 1 poll with 2 choices and 1 vote:
// File: database.js
module.exports = {
_indexes: {
polls: 2,
choices: 3,
votes: 2,
},
tables: {
polls: [{
id: 1,
title: 'Which one tastes better?',
closing_date: null,
result_visibility: 'public',
}],
choices: [{
id: 1,
poll_id: 1,
value: 'Pizza',
}, {
id: 2,
poll_id: 1,
value: 'Burger',
}],
votes: [{
id: 1,
poll_id: 1,
choice_id: 2,
user_name: 'Razvan',
user_ip_address: '::ffff:127.0.0.1',
}],
},
};
Below is a breakdown of each record type.
The poll object
This object represents a poll.
{
id: <integer>,
title: <string>,
closing_date: <timestamp|null>,
result_visibility: <"public"|"public_end"|"public_vote"|"private">
}
Where:
-
id
: The poll's index, derived from thedatabase._indexes.polls
property. -
title
: The poll's title. -
closing_date
: The optional poll's date and time in the Unix timestamp format after which participants cannot vote anymore. -
result_visibility
: The poll's vote results visibility, which can be either:-
"public"
to always display the votes. -
"public_end"
to display the votes onceclosing_date
is past. -
"public_vote"
to display the votes once the user has voted. -
"private"
to keep the votes hidden.
-
For example:
{
id: 1,
title: 'Which one tastes better?',
closing_date: null,
result_visibility: 'public'
}
The choice object
This object represents a single choice related to a poll.
{
id: <integer>,
poll_id: <integer>,
value: <string>
}
Where:
-
id
: The choice's index, derived from thedatabase._indexes.choices
property. -
poll_id
: The poll's index. -
value
: The choice's text value.
For example:
{
id: 1,
poll_id: 1,
value: 'Pizza',
}
The vote object
This object represents a vote for a choice.
{
id: <integer>,
poll_id: <integer>,
choice_id: <integer>,
user_name: <string>,
user_ip_address: <string>
}
Where:
-
id
: The vote's index, derived from thedatabase._indexes.votes
property. -
poll_id
: The poll's index. -
choice_id
: The choice's index. -
user_name
: The voter's username. -
user_ip_address
: The voter's IP address.
For example:
{
id: 1,
poll_id: 1,
choice_id: 2,
user_name: 'Razvan',
user_ip_address: '::ffff:127.0.0.1',
}
💡 New to backend programming in Node.js?
Check out the Learn Backend Mastery Program — a complete zero-to-hired roadmap that takes you from complete beginner to job-ready Node.js backend developer in 12 months.
Step 3: Create the API Endpoints
Let's create a new file named server.js
, and within it, write this minimal Express set up, where all the endpoints of the API will be attached to the pollRouter
router instance and mounted to the /poll
root path.
// File: server.js
const express = require('express');
const database = require('./database');
const server = express();
const pollRouter = express.Router();
// ...
server.use('/poll', pollRouter);
server.listen(3000);
Declare the GET /poll/:id endpoint
Let's declare a new HTTP GET endpoint responsible for retrieving an existing poll, that responds with an HTTP 501 Not Implemented
for now.
// File: server.js
const express = require('express');
const database = require('./database');
const server = express();
const pollRouter = express.Router();
pollRouter.get('/:id', (req, res) => {
res.sendStatus(501);
});
// ...
This endpoint takes a single route parameter named id
, that is a positive integer corresponding to the id
property of the poll object to retrieve from the database.
Declare the POST /poll/create endpoint
Let's declare a new HTTP POST endpoint responsible for creating a new poll, that uses the Express json()
middleware to parse and convert the message body of incoming requests into JSON objects, and that responds with an HTTP 501 Not Implemented
for now.
// File: server.js
const express = require('express');
const database = require('./database');
const server = express();
const pollRouter = express.Router();
pollRouter.get('/:id', (req, res) => {
res.sendStatus(501);
});
pollRouter.post('/create', express.json(), (req, res) => {
res.sendStatus(501);
});
// ...
The message body of incoming requests on this endpoint should have the following format:
{
poll: {
title: <string>
closing_date: <timestamp|null>,
result_visibility: <"public"|"public_end"|"public_vote"|"private">,
},
choices: [<string>],
}
Where:
-
poll.title
: The poll's title. Must be a string of maximum 255 characters. -
poll.closing_date
: The date and time limit after which users cannot vote anymore. Must be a Unix timestamp ornull
. -
poll.result_visibility
: The vote's visibility. Must be either"public"
,"public_end"
,"public_vote"
, or"private"
. If the value is"public_end"
, the value ofpoll.closing_date
must be non-null. -
choices
: The poll's choices. Must be an array of strings.
Declare the POST /poll/vote endpoint
Declare a new HTTP POST endpoint responsible for voting in an existing poll.
// File: server.js
const express = require('express');
const database = require('./database');
const server = express();
const pollRouter = express.Router();
pollRouter.get('/:id', (req, res) => {
res.sendStatus(501);
});
pollRouter.post('/create', express.json(), (req, res) => {
res.sendStatus(501);
});
pollRouter.post('/vote', express.json(), (req, res) => {
res.sendStatus(501);
});
// ...
The message body of incoming requests on this endpoint should have the following format:
{
choice_id: <integer>,
user_name: <string>,
}
Where:
-
choice_id
: The choice's index. Must be a positive integer. -
user_name
: The user's name. Must be a string of maximum 50 characters.
Step 4: Create the validation schemas
To validate and parse the parameters and message body of incoming HTTP requests, we'll use the Joi package to create a list of validation schemas, that will then be used by a custom middleware function to either reject invalid data or forward the request to the specified endpoint's controller.
Let's create a new file named schemas.js
, and within it, let's:
- Import the
joi
package. - Export an object literal that contains a property named after each endpoint.
// File: schemas.js
const Joi = require('joi');
module.exports = {
getPoll: {},
createPoll: {},
votePoll: {},
};
For each of these objects, let's add a property derived from the properties of the request object of the endpoint's controller, indicating where the data to validate comes from, where:
-
params
stands for route parameters -
query
stands for query string parameters -
body
stands for message body
// File: schemas.js
const Joi = require('joi');
module.exports = {
getPoll: {
params: null,
},
createPoll: {
body: null,
},
votePoll: {
body: null,
},
};
Define the "get poll" schema
Let's assign to the getPoll.params
property a Joi object with a single property named id
that corresponds to the route parameter of the HTTP GET /poll/:id
endpoint.
// File: schemas.js
const Joi = require('joi');
module.exports = {
getPoll: {
params: Joi.object({
id: Joi.number()
.integer()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.required(),
}),
},
// ...
};
Where:
-
id
is a mandatory integer with a minimum value of 1 and a maximum value defined by the built-inMAX_SAFE_INTEGER
constant.
Define the "create poll" schema
Let's assign to the createPoll.body
property a Joi object that corresponds to the expected message body of the HTTP POST /poll/create
endpoint.
// File: schemas.js
// ...
const minOneHourFromNow = (value, helpers) => {
if (value == null) return value;
const limit = new Date(Date.now() + 60 * 60 * 1000);
if (new Date(value) < limit) {
return helpers.error('date.min', { limit });
}
return value;
};
module.exports = {
// ...
createPoll: {
body: Joi.object({
poll: Joi.object({
title: Joi.string()
.max(255)
.trim()
.required(),
closing_date: Joi.alternatives().conditional('result_visibility', {
is: 'public_end',
then: Joi.date()
.custom(minOneHourFromNow, '>= 1h from now')
.required(),
otherwise: Joi.date()
.custom(minOneHourFromNow, '>= 1h from now')
.allow(null),
}),
result_visibility: Joi.string()
.valid('public', 'public_end', 'public_vote', 'private')
.default('public')
.required(),
}),
choices: Joi.array()
.items(Joi.string().trim().min(1))
.min(1)
.required(),
}),
},
// ...
};
Where:
-
poll.title
is a mandatory string of maximum 255 characters. -
poll.closing_date
is either a timestamp that's at least one hour away from now, and is mandatory if the value of thepoll.result_visibility
property is"public_end"
, ornull
otherwise. -
poll.result_visibility
is a mandatory string that's either"public"
,"public_end"
,"public_vote"
, or"private"
, and that defaults to"public"
. -
choices
is a mandatory array of strings of minimum 1 element of 1 character.
Note that the minOneHourFromNow()
helper function is used to ensure that the votes of the newly created poll don't close before at least one hour from its creation time.
Define the "vote poll" schema
Let's assign to the votePoll.body
property a Joi object that corresponds to the expected message body of the HTTP POST /poll/vote
endpoint.
// File: schemas.js
// ...
module.exports = {
// ...
votePoll: {
body: Joi.object({
choice_id: Joi.number()
.integer()
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.required(),
user_name: Joi.string()
.max(50)
.required(),
}),
},
};
Where:
-
choice_id
is a mandatory integer with a minimum value of 1 and a maximum value defined by the built-inMAX_SAFE_INTEGER
constant. -
user_name
is a mandatory string of maximum 50 characters.
Step 5: Implement a validation middleware
Let's create a custom Express validation middleware that uses the Joi schemas we've just defined to validate and normalize the data of incoming requests on the various endpoints of our API and either:
- Rejects the incoming request with an HTTP
400 Bad Request
if any of the values doesn't match the format define by the corresponding schema. - Normalizes the values and forwards the request to the endpoint's controller.
Let's create a new file named schema-validator.js
, and within it, let's:
- Import the schemas module.
- Declare a factory function that takes as parameters a configuration object and returns an Express middleware.
// File: schema-validator.js
const SCHEMAS = require('./schemas');
module.exports = (config) => (req, res, next) => {
//
};
Where in the config
object:
- The
schema
property is the name of the schema to use, includinggetPoll
,createPoll
, andvotePoll
. - The
source
property is the name of the request's object property to check, includingparams
for route parameters,query
for query string parameters, andbody
for message body.
For example:
schemaValidator({ schema: 'getPoll', source: 'params' });
schemaValidator({ schema: 'createPoll', source: 'body' });
schemaValidator({ schema: 'votePoll', source: 'body' });
Within the middleware function, let's first respond with an HTTP 500 Internal Server Error
if the specified name or source within the schemas doesn't exist.
// File: schema-validator.js
const SCHEMAS = require('./schemas');
module.exports = (config) => (req, res, next) => {
let schema = SCHEMAS?.[config.schema]?.[config.source];
if (!schema) {
return res.status(500).json({ error: 'Validation schema unknown' });
}
};
Invoke the validate()
method of the schema to validate and normalize the values of the request's object corresponding to the specified source.
// File: schema-validator.js
const SCHEMAS = require('./schemas');
module.exports = (config) => (req, res, next) => {
let schema = SCHEMAS?.[config.schema]?.[config.source];
if (!schema) {
return res.status(500).json({ error: 'Validation schema unknown' });
}
const { value, error } = schema.validate(req[config.source], { convert: true });
};
Note that in order to convert string values passed as route parameters to integers, such as
id
, we need to set theconvert
option of thevalidate()
method totrue
.
Finally, let's either:
- Respond with an HTTP
400 Bad Request
if thevalidate()
method returns an error. - Add the normalized values to the request's object corresponding to the specified source, and forward the request to the endpoint's controller.
// File: schema-validator.js
const SCHEMAS = require('./schemas');
module.exports = (config) => (req, res, next) => {
let schema = SCHEMAS?.[config.schema]?.[config.source];
if (!schema) {
return res.status(500).json({ error: 'Validation schema unknown' });
}
const { value, error } = schema.validate(req[config.source], { convert: true });
if (error) {
return res.status(400).json({ error: error.message, source: config.source });
}
req[config.source] = { ...req[config.source], ...value };
next();
};
Use the validation middleware
Within the server.js
module, let's import the schema validation middleware, and invoke it on every route with the appropriate configuration object.
// File: server.js
const express = require('express');
const database = require('./database');
const schemaValidator = require('./schema-validator');
const server = express();
const pollRouter = express.Router();
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
res.sendStatus(501);
});
pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
res.sendStatus(501);
});
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
res.sendStatus(501);
});
// ...
Step 6: Implement the GET /poll/:id endpoint
Within the HTTP GET /poll/:id
endpoint, let's first attempt to retrieve the poll object corresponding to the id
route parameter and respond with an HTTP 404 Not Found
if the poll doesn't exist.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
const poll = database.tables.polls.find(poll => poll.id === req.params.id);
if (!poll) {
return res.status(404).json({ error: 'Poll not found' });
}
});
Let's declare a data
object that will contain the message body of the response, and include the poll's title, and optionally the list of choices and vote summary.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
let data = {
form: {
title: poll.title,
choices: [],
},
votes: [],
};
});
Let's retrieve the choice and vote objects of the related poll.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
const choices = database.tables.choices.filter(choice => choice.poll_id === req.params.id);
const votes = database.tables.votes.filter(vote => vote.poll_id === req.params.id);
});
Let's check if the user has already voted by checking if the client's IP address exists in the list of votes, and save the current Unix timestamp.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
const hasUserVoted = votes.find(vote => vote.user_ip_address === req.ip);
const timestamp = Date.now();
});
Let's iterate on every element of the choice list using a for...of
loop.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
for (let choice of choices) {
//
}
});
Let's include each choice in the data
object if the user hasn't voted yet.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
for (let choice of choices) {
if (!hasUserVoted) {
data.form.choices.push({
id: choice.id,
value: choice.value,
});
}
}
});
Let's include the vote summary in the data
object if the poll's visibility is:
- Set to
"public"
. - Set to
"public_end"
and the current timestamp is greater than the value of the poll'sclosing_date
property. - Set to
"public_vote"
and the user has already voted.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
for (let choice of choices) {
if (!hasUserVoted) {
data.form.choices.push({
id: choice.id,
value: choice.value,
});
}
if (
(poll.result_visibility === 'public') ||
(poll.result_visibility === 'public_end' && timestamp > poll.closing_date) ||
(poll.result_visibility === 'public_vote' && hasUserVoted)
) {
data.votes.push({
value: choice.value,
total: votes.filter(vote => choice.id === vote.choice_id).length
});
}
}
});
Finally, let's respond to the client with an HTTP 200 OK
containing the data
object in the JSON format.
pollRouter.get('/:id', schemaValidator({ schema: 'getPoll', source: 'params' }), (req, res) => {
// ...
res.json(data);
});
Test the endpoint
To test this endpoint, let's add the following objects into the database.js
module.
// File: database.js
module.exports = {
_indexes: {
polls: 4,
choices: 7,
votes: 2,
},
tables: {
polls: [
{ id: 1, title: 'Which one tastes better?', closing_date: null, result_visibility: 'public' },
{ id: 2, title: 'Will Santa come at midnight?', closing_date: 1767221999000, result_visibility: 'public_end' },
{ id: 3, title: 'Which car is faster?', closing_date: 1767221999000, result_visibility: 'public_vote' },
],
choices: [
{ id: 1, poll_id: 1, value: 'Pizza' },
{ id: 2, poll_id: 1, value: 'Burger' },
{ id: 3, poll_id: 2, value: 'Yes' },
{ id: 4, poll_id: 2, value: 'No' },
{ id: 5, poll_id: 3, value: 'Lamborghini' },
{ id: 6, poll_id: 3, value: 'Ferrari' },
],
votes: [
{ id: 1, poll_id: 1, choice_id: 2, user_name: 'Jackson', user_ip_address: '::ffff:127.0.0.1' }
],
},
};
And let's start the server:
node server.js
When querying the poll with an invalid id
, it should respond with an HTTP 400 Bad Request
.
$ curl -i 127.0.0.1:3000/poll/x
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 57
ETag: W/"39-mR6PkOBbhBJJAJ4fA9g4i+VgqDU"
Date: Sat, 11 Oct 2025 11:03:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"\"id\" must be a number","source":"params"}
When querying the poll whose id
equals 1
, it should respond with an HTTP 200 OK
containing the votes but not the choices, since the user has already voted, as recorded by the IP address ::ffff:127.0.0.1
.
$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 121
ETag: W/"79-XpGX35CnKNaFzfSTxi49RnHyEFU"
Date: Sat, 11 Oct 2025 10:36:20 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Which one tastes better?","choices":[]},"votes":[{"value":"Pizza","total":0},{"value":"Burger","total":1}]}
When querying the poll whose id
equals 2
, it should respond with an HTTP 200 OK
containing the choices but not the votes, since the poll's closing date is set to the 1767221999000
timestamp, which is equivalent to 2025/12/31 at 23:59:59.
$ curl -i 127.0.0.1:3000/poll/2
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 117
ETag: W/"75-91j3tJqqn1xsMWS5VSzIOGJEtjk"
Date: Sat, 11 Oct 2025 10:36:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Will Santa come at midnight?","choices":[{"id":3,"value":"Yes"},{"id":4,"value":"No"}]},"votes":[]}
When querying the poll whose id
equals 3
, it should respond with an HTTP 200 OK
containing the choices but not the votes, since the poll's votes visibility is set to "public_vote"
and the user hasn't voted yet.
$ curl -i 127.0.0.1:3000/poll/3
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 122
ETag: W/"7a-k5DedEni/OTb8wTy6MhpJ4G1opI"
Date: Sat, 11 Oct 2025 10:57:40 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Which car is faster?","choices":[{"id":5,"value":"Lamborghini"},{"id":6,"value":"Ferrari"}]},"votes":[]}
When querying the poll whose id
equals 4
, it should respond with an HTTP 404 Not Found
.
$ curl -i 127.0.0.1:3000/poll/4
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: text/plain; charset=utf-8
Content-Length: 9
ETag: W/"9-0gXL1ngzMqISxa6S1zx3F4wtLyg"
Date: Sat, 11 Oct 2025 10:36:25 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"Poll not found"}
Step 7: Implement the POST /poll/create endpoint
Within the HTTP POST /poll/create
endpoint, let's create a new poll object using the data parsed by the validation middleware and assign it the next id
available from the indexes object, while post-incrementing it at the same time.
pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
const poll = {
id: database._indexes.polls++,
...req.body.poll,
};
});
Let's store the object into the database.polls
table.
pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
const poll = {
id: database._indexes.polls++,
...req.body.poll,
};
database.tables.polls.push(poll);
});
Let's store each choice into the choices table, and assign it the next id
available, while post-incrementing it at the same time.
pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
// ...
for (let choice of req.body.choices) {
database.tables.choices.push({
id: database._indexes.choices++,
poll_id: poll.id,
value: choice,
});
}
});
And finally, let's respond with an HTTP 200 OK
containing the newly created poll's id
.
pollRouter.post('/create', express.json(), schemaValidator({ schema: 'createPoll', source: 'body' }), (req, res) => {
// ...
res.json({ poll_id: poll.id });
});
Test the endpoint
To test this endpoint, let's reset the object exported by the database.js
module.
// File: database.js
module.exports = {
_indexes: {
polls: 1,
choices: 1,
votes: 1,
},
tables: {
polls: [],
choices: [],
votes: [],
},
};
Let's kill (using CTRL+C
) and restart the server.
$ node server.js
^C
$ node server.js
When creating a poll with a missing list of choices, it should respond with an HTTP 400 Bad Request
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public"}}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 51
ETag: W/"33-iUbkFOxzZLKsXJpI+yPu7hLKxzA"
Date: Sat, 11 Oct 2025 11:23:45 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"\"choices\" is required","source":"body"}
When creating a poll with a result visibility set to "public_end"
and a closing date set to null
, it should respond with an HTTP 400 Bad Request
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 70
ETag: W/"46-ezrMA+0QQL71WIgvCuXx5h4oM1s"
Date: Sat, 11 Oct 2025 11:24:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"\"poll.closing_date\" must be a valid date","source":"body"}
When creating a poll with a closing date set to a past timestamp, it should respond with an HTTP 400 Bad Request
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":1735722000000,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 111
ETag: W/"6f-mHVPvcnfmJfLGpoqQasL3kO5gU8"
Date: Sat, 11 Oct 2025 11:25:02 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"\"poll.closing_date\" must be greater than or equal to \"2025-10-11T12:25:02.121Z\"","source":"body"}
When creating a valid poll, it should respond with an HTTP 200 OK
containing the poll's id
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":1767221999000,"result_visibility":"public_end"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-cHff1Ljgd50XiLS5Skq4iqxEZCg"
Date: Sat, 11 Oct 2025 11:25:23 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"poll_id":1}
When querying the poll using the id
returned by the previous request, it should respond with an HTTP 200 OK
containing the poll's data.
$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 119
ETag: W/"77-QQ4/koZFbRWbvl/yYAKAyB6OkZs"
Date: Sat, 11 Oct 2025 11:25:48 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Which one tastes better?","choices":[{"id":1,"value":"Pizza"},{"id":2,"value":"Burger"}]},"votes":[]}
Step 8: Implement the POST /poll/vote endpoint
Within the HTTP POST /poll/vote
endpoint, let's first retrieve the choice object from the database corresponding to the choice_id
property of the request's message body, and respond with an HTTP 404 Not Found
if it doesn't exist.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
const choice = database.tables.choices.find(choice => choice.id === req.body.choice_id);
if (!choice) {
return res.status(404).json({ error: 'Choice not found' });
}
});
Let's retrieve the poll object from the database corresponding to the id
property of the retrieved choice object, and respond with an HTTP 404 Not Found
if it doesn't exist.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
// ...
const poll = database.tables.polls.find(poll => poll.id === choice.poll_id);
if (!poll) {
return res.status(404).json({ error: 'Poll not found' });
}
});
Let's check if the closing_date
property of the poll object is set, and respond with an HTTP 403 Forbidden
if the current timestamp is greater, which means that the poll is closed and users can't vote anymore.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
// ...
if (poll.closing_date && Date.now() > poll.closing_date) {
return res.status(403).json({ error: 'Poll is closed' });
}
});
Let's check if the client's IP address exists in the poll's vote list, and respond with an HTTP 403 Forbidden
if it does, which means that the user has already voted.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
// ...
const hasUserVoted = database.tables.votes.findIndex(vote => vote.poll_id === choice.poll_id && vote.user_ip_address === req.ip);
if (hasUserVoted !== -1) {
return res.status(403).json({ error: 'User has already voted' });
}
});
Let's insert a new vote object into the votes table, including the vote's index, the poll's index, the data from the message body, and the client's IP address.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
// ...
const vote = {
id: database._indexes.votes++,
poll_id: choice.poll_id,
...req.body,
user_ip_address: req.ip
};
database.tables.votes.push(vote);
});
Finally, let's respond with an HTTP 200 OK
containing the id
property of the vote object.
pollRouter.post('/vote', express.json(), schemaValidator({ schema: 'votePoll', source: 'body' }), (req, res) => {
// ...
res.json({ vote_id: vote.id });
});
Test the endpoint
To test this endpoint, let's kill and restart the server.
$ node server.js
^C
$ node server.js
When creating a new poll, it should respond with an HTTP 200 OK
containing the poll's id
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"poll":{"title":"Which one tastes better?","closing_date":null,"result_visibility":"public_vote"},"choices":["Pizza","Burger"]}' 127.0.0.1:3000/poll/create
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-cHff1Ljgd50XiLS5Skq4iqxEZCg"
Date: Sat, 11 Oct 2025 15:38:14 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"poll_id":1}
When querying the poll we've just created using the id returned by the previous query, it should respond with an HTTP 200 OK
containing the poll's data, including the choices but not the votes, since the poll's result visibility is set to "public_vote"
.
$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 119
ETag: W/"77-QQ4/koZFbRWbvl/yYAKAyB6OkZs"
Date: Sat, 11 Oct 2025 15:38:21 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Which one tastes better?","choices":[{"id":1,"value":"Pizza"},{"id":2,"value":"Burger"}]},"votes":[]}
When voting for a poll with an invalid payload, it should respond with an HTTP 400 Bad Request
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2}' 127.0.0.1:3000/poll/vote
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 53
ETag: W/"35-e+Aj2lGPB4w4JfvHCy/ykC8/7G8"
Date: Sat, 11 Oct 2025 15:53:58 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"\"user_name\" is required","source":"body"}
When voting for a poll with a non-existent choice id, it should respond with an HTTP 404 Not Found.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":3,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 28
ETag: W/"1c-1cCU5TxdKUmlTxzbj9LSDLwyjYM"
Date: Sat, 11 Oct 2025 15:56:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"Choice not found"}
When voting for a poll with a valid payload and an existing choice id
, it should respond with an HTTP 200 OK
containing the newly created vote's id
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 13
ETag: W/"d-nisMVKjWYQ6K/DygNQyFrZoaFXU"
Date: Sat, 11 Oct 2025 15:38:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"vote_id":1}
When voting again for the same poll, it should respond with an HTTP 403 Forbidden
.
$ curl -i -X POST -H 'Content-Type: application/json' -d '{"choice_id":2,"user_name":"Jackson"}' 127.0.0.1:3000/poll/vote
HTTP/1.1 403 Forbidden
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 34
ETag: W/"22-zN9pQ6+RFSObob4HECP2Wn+TtDc"
Date: Sat, 11 Oct 2025 15:44:59 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"User has already voted"}
Finally, when querying the poll again, it should respond with an HTTP 200 OK
containing the poll's data, including the votes but not the choices, since the user has already voted.
$ curl -i 127.0.0.1:3000/poll/1
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 125
ETag: W/"7d-mNH3QPy4zUlnq89V201JwkrDefI"
Date: Sat, 11 Oct 2025 15:38:36 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"form":{"title":"Which one tastes better?","choices":[]},"votes":[{"value":"Pizza","total":0},{"value":"Burger","total":1}]}
Congratulations! 🎉
You now have a working Strawpoll-like API built with Node.js, Express and Joi. 🎉
Want to Learn Backend the Right Way?
✅ Run this project on your own machine - download the source code here.
🚀 If you enjoyed this article and want to go beyond snippets, check out the Learn Backend Mastery program — the complete zero-to-hired roadmap to becoming a job-ready Node.js backend developer, including:
- 136 premium lessons across CLI, JavaScript, Node.js, MySQL, Express, Git, and more.
- 25 full-scale projects + commented solutions
- Visual progress tracking.
- Exclusive student Discord channel
- Lifetime access and all future updates
Top comments (0)