We will be creating a user profile store and session for a blog API using Node, Couchbase's NodeJS SDK 3 and we'll review basic data modeling techniques for our document structure specific to working with a document database.
Let’s define some rules around our very basic user profile store concept:
- Store account data like username and password in a
profile
document - Pass sensitive user data with each user action request
- Use a session that expires after a set amount of time
- Stored
session
documents with an expiry limit
We can manage all of this with the following API endpoints:
- POST
/account
– Create a new user profile with account information - POST
/login
– Validate account information - GET
/account
– Get account information - POST
/blog
– Create a new blog entry associated to a user - GET
/blogs
– Get all blog entries for a particular user
These endpoints will be part of our Express based REST API.
If you already have COuchbase running you can skip to the tutorial.
The following shell command will setup a Couchbase Docker container with the name: cb
using the official Couchbase Docker image.
docker pull couchbase
docker run -d --name cb -p 8091-8096:8091-8096 -p 11210-11211:11210-11211 couchbase
With your databse running locally you can access it at localhost:8091, set up a one node cluster:
- Set Cluster Name (
Blog Tutorial
) - Set Admin User (
Administrator
) - Set Password (
password
) - Accept Terms
- Configure Disk, Memory, Services (Check Data, Query, and Index at minimum)
Once logged in create a new bucket.
- Select Bucket Tab
- Click "ADD BUCKET"
- Name your bucket
blog
Let’s create a project directory for our Node.js app and install our dependencies.
mkdir blog-api && cd blog-api && npm init -y && touch server.js /
npm install couchbase express body-parser uuid bcryptjs cors nodemon --save && code .
This creates a working directory for our project and initializes a new Node project, create a server.js
installs required dependencies and opens VS Code.
Our dependencies include the Node.js SDK for Couchbase and Express Framework and other utility libraries like body-parser
to accept JSON data via POST requests, uuid
for generating unique keys and bcryptjs
to hash our passwords to deter malicious users, and nodemon
a tool that helps develop node.js based applications by automatically restarting the node application when file changes.
Let’s bootstrap our application with a server.js
file:
const couchbase = require('couchbase')
const express = require('express')
const uuid = require('uuid')
const bodyParser = require('body-parser')
const bcrypt = require('bcryptjs')
const cors = require('cors')
const app = express()
app.use(cors())
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))
const cluster = new couchbase.Cluster('couchbase://localhost', {
username: 'Administrator', password: 'password'
})
const bucket = cluster.bucket('blog')
const collection = bucket.defaultCollection()
/* All Code Goes Here */
const server = app.listen(3000, () => console.info(`Running on port ${server.address().port}...`))
The above code requires our dependencies and initializes an Express app running on port 3000 against Couchbase Server using a bucket named blog
.
Before we create the intended API code for our project, we should go over some basics in Express. Let's set up an endpoint that will utilize the Couchbase NodeJS SDK to do a basic key-value get operation.
The most efficient way to query a document database is to ask for one document by its key. No indexing is required, we simply supply the key and in return get our document back.
First we need to add a document to our blog
bucket in Couchbase Server:
- On the Bucket tab, click on the
blog
bucket's "document" link - In the upper right hand corner of the screen there is an "ADD DOCUMENT" button.
- It will prompt you for the ID, enter:
1234
and click "Save" - Then it will ask for the JSON value:
{
"type": "profile",
"email": "[email protected]"
}
With that document in place, let's create an endpoint with the sole purpose of getting a single document.
Add the following code to the project:
app.get("/profile/:pid", async(request, response) => {
try {
const result = await collection.get(request.params.pid)
response.send(result)
} catch (e) {
return response.status(500).send(e.message)
}
})
The route for this endpointis /profile/
and it accepts a request parameter :pid
. Considering the document we just created we could call this endpoint from Postman using: localhost:3000/profile/1234
.
In the Postman collections that are included in this repo, we will have a request named: get profile
, we can use that to test that our new API endpoint works.
In the terminal run: nodemon server
In Postman run the get profile
request using localhost:3000/profile/1234
* This was just for demonstration purposes and to get our feet wet, we can remove the document that we added to the database and the code we just added to our server.js
.
Our user profile can have any information describing a user
like address
, phone
, and other social media info. It is never a good idea to store account credentials in the same document as our basic profile information. We’ll need a minimum of two documents for every user.
Our Profile document will have a key that we will refer to in our related documents. This key is an auto-generated UUID: b181551f-071a-4539-96a5-8a3fe8717faf
.
It's value will includes two properties: email
and type
. The type
property is an important indicator that describes our document similar to how a table organizes records in a relational database.
{
"type": "profile",
"email": "[email protected]"
}
The account document associated with our profile will have a key that is equal to our user’s email:
[email protected]
and just as before, this document also will have a type
, as well as a pid
referring to the key of our profile document and a hashed password
.
{
"type": "account",
"pid": "b181551f-071a-4539-96a5-8a3fe8717faf",
"email": "[email protected]",
"password": "$2a$10$tZ23pbQ1sCX4BknkDIN6NekNo1p/Xo.Vfsttm.USwWYbLAAspeWsC"
}
Great, we have established a model for each document and a strategy for relating those documents without database constraints.
Add the following code to our server.js
just above the last line of code which starts ourt server:
app.post("/account", async (request, response) => {
if (!request.body.email && !request.body.password) {
return response.status(401).send({ "message": "An `email` and `password` are required" })
} else if (!request.body.email || !request.body.password) {
return response.status(401).send({
"message": `A ${!request.body.email ? '`email`' : '`password`'} is required`
})
}
const id = uuid.v4()
const account = {
"type": "account",
"pid": id,
"email": request.body.email,
"password": bcrypt.hashSync(request.body.password, 10)
}
const profile = {
"type": "profile",
"email": request.body.email
}
await collection.insert(id, profile)
.then(async () => {
await collection.insert(request.body.email, account)
.then((result) => {
result.pid = id
return response.send(result)
})
.catch(async (e) => {
await collection.remove(id)
.then(() => {
console.error(`account creation failed, removed: ${id}`)
return response.status(500).send(e)
})
.catch(e => response.status(500).send(e))
})
})
.catch(e => response.status(500).send(e))
})
Let’s break this code down.
First, we check that both an email
and password
exist in the request.
Then we start buidling a local copy of the documents we want to persist to the database:
Rather than saving the password in the account
object as plain text, we hash it with Bcrypt. For more info on password hashing, check out this tutorial.
With the data ready, we can insert it into Couchbase.
Next, we start by inserting the profile
document using collection.insert
and passing it the key: id
and document: profile
.
From here we either have success or an error. If an error occurs we catch, return a status of 500
and send back the error.
Otherwise if we get a success, we try to insert the account
document.
* We want both the account and profile documents to be created successfully, otherwise we need to roll it all back.
If an error occurs we catch, rollback the document creation by deleting the prfile
document we just added, then return a status of 500
and send back the error.
Otherwise if we get a success, we insert the account
document, use the pid
from the result we get back from Couchbase and add that property to the response object and send that back as a response, this will be seen by the client as a 200ok
response and they will get back the following object:
{
"cas": "1614421293851803648",
"token": "553:133005712768050:1:blog",
"pid": "b181551f-071a-4539-96a5-8a3fe8717faf"
}
With the user profile and account created, the user can sign-in which will create a session document.
We want to log in and establish a session that will be stored in the database referencing our user profile by pid
. This document will eventually expire. Upon expiration, this document will be automatically removed from the database. Any activity beyond that point will require a new login and session. We also have the ability to update the expiration time if we need to (we will cover this later in the tutorial).
The session model will look like the following:
{
"type": "session",
"id": "ce0875cb-bd27-48eb-b561-beee33c9f405",
"pid": "b181551f-071a-4539-96a5-8a3fe8717faf"
}
Just like the account document, session has a pid
property that references our user's profile.
The code that makes this possible is in the login endpoint:
app.post("/login", async (request, response) => {
if (!request.body.email && !request.body.password) {
return response.status(401).send({ "message": "An `email` and `password` are required" })
} else if (!request.body.email || !request.body.password) {
return response.status(401).send({
"message": `A ${!request.body.email ? '`email`' : '`password`'} is required`
})
}
await collection.get(request.body.email)
.then(async (result) => {
if (!bcrypt.compareSync(request.body.password, result.value.password)) {
return response.status(500).send({ "message": "Password invalid" })
}
var session = {
"type": "session",
"id": uuid.v4(),
"pid": result.value.pid
}
await collection.insert(session.id, session, { "expiry": 3600 })
.then(() => response.send({ "sid": session.id }))
.catch(e => response.status(500).send(e))
})
.catch(e => response.status(500).send(e))
})
After validating the incoming data we do an account lookup by email address. If data comes back for the email, we can compare the incoming password with the hashed password returned in the account lookup. Provided this succeeds, we can create a new session for the user.
For this document we set an expiration of one hour (3600 s). If the expiration isn’t refreshed, the document will disappear. This is good because it forces the user to sign-in again and get a new session. This session token will be passed with every future request instead of the password.
We want to get information about our user profile as well as associate new things to the profile, we confirm authority through the session.
We can confirm the session is valid using some Express middleware. A Middleware function can be added to any Express endpoint. This validation is a simple function that will have access to our endpoint’s HTTP request:
const validate = async(request, response, next) => {
const authHeader = request.headers["authorization"]
if (authHeader) {
bearerToken = authHeader.split(" ")
if (bearerToken.length == 2) {
await collection.get(bearerToken[1])
.then(async(result) => {
request.pid = result.value.pid
await collection.touch(bearerToken[1], 3600)
.then(() => next())
.catch((e) => console.error(e.message))
})
.catch((e) => response.status(401).send({ "message": "Invalid session token" }))
}
} else {
response.status(401).send({ "message": "An authorization header is required" })
}
}
Here we are checking the request for an authorization header. If we have a valid bearer token with session id (sid), we can do a lookup. The session document has the pid
in it. If the session lookup is successful, we save the pid
in the request.
Next, we refresh the session expiration and move through the middleware and back to the endpoint. If the session doesn’t exist, no pid
will be passed and the request will fail.
* Note: Now that we know how middleware works, challenge yourself to create a midleware to check for email and password on the /account
and /login
POST endpoints. It should be as easy as extracting the duplicated code to its own fuction, adding a call to next()
after the if/esle and adding the newly named function as a second argument to the existing endpoints.
Now we can use our middleware to get information about our profile in our account endpoint:
app.get("/account", validate, async (request, response) => {
try {
await collection.get(request.pid)
.then((result) => response.send(result.value))
.catch((e) => response.status(500).send(e))
} catch (e) {
console.error(e.message)
}
})
Notice the validate
happens first and then the rest of the request. The request.pid
was established by the middleware and it will get us a particular profile document for that id.
Next, we create an endpoint to add a blog article for the user:
app.post("/blog", validate, async(request, response) => {
if (!request.body.title && !request.body.content) {
return response.status(401).send({ "message": "A `title` and `content` are required for each blog post" })
} else if (!request.body.title || !request.body.content) {
return response.status(401).send({
"message": `A ${!request.body.title ? '`title`' : '`content`'} is required for each blog post`
})
}
var blog = {
"type": "blog",
"pid": request.pid,
"title": request.body.title,
"content": request.body.content,
"timestamp": (new Date()).getTime()
}
const uniqueId = uuid.v4()
collection.insert(uniqueId, blog)
.then(() => response.send(blog))
.catch((e) => response.status(500).send(e))
})
Assuming the middleware succeeded, we create a blog object with a specific type
and pid
. Then we can save it to the database.
Querying for all blog posts by a particular user isn’t too much different:
app.get("/blogs", validate, async(request, response) => {
try {
const query = `SELECT * FROM `blog` WHERE type = 'blog' AND pid = $PID;`
const options = { parameters: { PID: request.pid } }
await cluster.query(query, options)
.then((result) => response.send(result.rows))
.catch((e) => response.status(500).send(e))
} catch (e) {
console.error(e.message)
}
})
Because we need to query by document property rather than document key, we’ll use a N1QL query to return all documents for that particular profile.
Before we can call this endpoint or run the N1QL statement, we need to create a secodnary index.
A secondary index is an index on any key-value or document-key. This index can use any key within the document and the key can be of any type: scalar, object, or array. In our case we are going to index on the type
and pid
, these are simple scalar values.
This is a special kind of secondary index called a Composite Secondary Index which want to use for performance reasons because we are querying on type = 'blog' AND pid = $PID
.
If a query is referencing only the keys in the index, the query engine can simply answer the query from the index scan result without having to fetch from the node(s).
If we access our Couchbase Server web console running locally on localhost:8091
, we can click on the Query tab and execute this statement in the Query Editor:
CREATE INDEX `blogbyuser` ON `blog`(type, pid);
Since we will obtain all blog posts for a particular profile id, we’ll get better performance using this specific index rather than a general primary index. Primary Indexes are not recommended for production-level code.
You just saw how to create a user profile store and session using Node.js and NoSQL.
As previously mentioned, the account documents could represent a form of login credentials where you could have a document for basic authentication (Facebook authentication, etc.) referring to the same profile document. Instead of using a UUID for the session, a JSON Web Token (JWT) or maybe something more secure could be used.
The finished code, Postman collections, and environment variables available in the couchbaselabs / couchbase-nodejs-blog-api repo on GitHub.