RedisOM for Node.js
Learn how to build with Redis Stack and Node.js
This tutorial will show you how to build an API using Node.js and Redis Stack.
We'll be using Express and Redis OM to do this, and we assume that you have a basic understanding of Express.
The API we'll be building is a simple and relatively RESTful API that reads, writes, and finds data on persons: first name, last name, age, etc. We'll also add a simple location tracking feature just for a bit of extra interest.
But before we start with the coding, let's start with a description of what Redis OM is.
Prerequisites
Like anything software-related, you need to have some dependencies installed before you can get started:
- Node.js 14.8+: In this tutorial, we're using JavaScript's top-level
await
feature which was introduced in Node 14.8. So, make sure you are using that version or later. - Redis Stack: You need a version of Redis Stack, either running locally on your machine or in the cloud.
- Redis Insight: We'll use this to look inside Redis and make sure our code is doing what we think it's doing.
Starter code
We're not going to code this completely from scratch. Instead, we've provided some starter code for you. Go ahead and clone it to a folder of your convenience:
git clone git@github.com:redis-developer/express-redis-om-workshop.git
Now that you have the starter code, let's explore it a bit. Opening up server.js
in the root we see that we have a simple Express app that uses Dotenv for configuration and Swagger UI Express for testing our API:
import 'dotenv/config'
import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'
/* create an express app and use JSON */
const app = new express()
app.use(express.json())
/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))
/* start the server */
app.listen(8080)
Alongside this is api.yaml
, which defines the API we're going to build and provides the information Swagger UI Express needs to render its UI. You don't need to mess with it unless you want to add some additional routes.
The persons
folder has some JSON files and a shell script. The JSON files are sample persons—all musicians because fun—that you can load into the API to test it. The shell script—load-data.sh
—will load all the JSON files into the API using curl
.
There are two empty folders, om
and routers
. The om
folder is where all the Redis OM code will go. The routers
folder will hold code for all of our Express routes.
Configure and run
The starter code is perfectly runnable if a bit thin. Let's configure and run it to make sure it works before we move on to writing actual code. First, get all the dependencies:
npm install
Then, set up a .env
file in the root that Dotenv can make use of. There's a sample.env
file in the root that you can copy and modify:
cp sample.env .env
The contents of .env
looks like this:
# Put your local Redis Stack URL here. Want to run in the
# cloud instead? Sign up at https://redis.com/try-free/.
REDIS_URL=redis://localhost:6379
There's a good chance this is already correct. However, if you need to change the REDIS_URL
for your particular environment (e.g., you're running Redis Stack in the cloud), this is the time to do it. Once done, you should be able to run the app:
npm start
Navigate to http://localhost:8080
and check out the client that Swagger UI Express has created. None of it works yet because we haven't implemented any of the routes. But, you can try them out and watch them fail!
The starter code runs. Let's add some Redis OM to it so it actually does something!
Setting up a Client
First things first, let's set up a client. The Client
class is the thing that knows how to talk to Redis on behalf of Redis OM. One option is to put our client in its own file and export it. This ensures that the application has one and only one instance of Client
and thus only one connection to Redis Stack. Since Redis and JavaScript are both (more or less) single-threaded, this works neatly.
Let's create our first file. In the om
folder add a file called client.js
and add the following code:
import { Client } from 'redis-om'
/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL
/* create and open the Redis OM Client */
const client = await new Client().open(url)
export default client
Remember that top-level await stuff we mentioned earlier? There it is!
Note that we are getting our Redis URL from an environment variable. It was put there by Dotenv and read from our .env
file. If we didn't have the .env
file or have a REDIS_URL
property in our .env
file, this code would gladly read this value from the actual environment variables.
Also note that the .open()
method conveniently returns this
. This this
(can I say this again? I just did!) lets us chain the instantiation of the client with the opening of the client. If this isn't to your liking, you could always write it like this:
/* create and open the Redis OM Client */
const client = new Client()
await client.open(url)
Entity, Schema, and Repository
Now that we have a client that's connected to Redis, we need to start mapping some persons. To do that, we need to define an Entity
and a Schema
. Let's start by creating a file named person.js
in the om
folder and importing client
from client.js
and the Entity
and Schema
classes from Redis OM:
import { Entity, Schema } from 'redis-om'
import client from './client.js'
Entity
Next, we need to define an entity. An Entity
is the class that holds you data when you work with it—the thing being mapped to. It is what you create, read, update, and delete. Any class that extends Entity
is an entity. We'll define our Person
entity with a single line:
/* our entity */
class Person extends Entity {}
Schema
A schema defines the fields on your entity, their types, and how they are mapped internally to Redis. By default, entities map to JSON documents. Let's create our Schema
in person.js
:
/* create a Schema for Person */
const personSchema = new Schema(Person, {
firstName: { type: 'string' },
lastName: { type: 'string' },
age: { type: 'number' },
verified: { type: 'boolean' },
location: { type: 'point' },
locationUpdated: { type: 'date' },
skills: { type: 'string[]' },
personalStatement: { type: 'text' }
})
When you create a Schema
, it modifies the Entity
class you handed it (Person
in our case) adding getters and setters for the properties you define. The type those getters and setters accept and return are defined with the type parameter as shown above. Valid values are: string
, number
, boolean
, string[]
, date
, point
, and text
.
The first three do exactly what you think—they define a property that is a String
, a Number
, or a Boolean
. string[]
does what you'd think as well, specifically defining an Array
of strings.
date
is a little different, but still more or less what you'd expect. It defines a property that returns a Date
and can be set using not only a Date
but also a String
containing an ISO 8601 date or a Number
with the UNIX epoch time in milliseconds.
A point
defines a point somewhere on the globe as a longitude and a latitude. It creates a property that returns and accepts a simple object with the properties of longitude
and latitude
. Like this:
let point = { longitude: 12.34, latitude: 56.78 }
A text
field is a lot like a string
. If you're just reading and writing objects, they are identical. But if you want to search on them, they are very, very different. We'll talk about search more later, but the tl;dr is that string
fields can only be matched on their whole value—no partial matches—and are best for keys while text
fields have full-text search enabled on them and are optimized for human-readable text.
Repository
Now we have all the pieces that we need to create a repository. A Repository
is the main interface into Redis OM. It gives us the methods to read, write, and remove a specific Entity
. Create a Repository
in person.js
and make sure it's exported as you'll need it when we start implementing out API:
/* use the client to create a Repository just for Persons */
export const personRepository = new Repository(personSchema, client)
We're almost done with setting up our repository. But we still need to create an index or we won't be able to search. We do that by calling .createIndex()
. If an index already exists and it's identical, this function won't do anything. If it's different, it'll drop it and create a new one. Add a call to .createIndex()
to person.js
:
/* create the index for Person */
await personRepository.createIndex()
That's all we need for person.js
and all we need to start talking to Redis using Redis OM. Here's the code in its entirety:
import { Entity, Schema } from 'redis-om'
import client from './client.js'
/* our entity */
class Person extends Entity {}
/* create a Schema for Person */
const personSchema = new Schema(Person, {
firstName: { type: 'string' },
lastName: { type: 'string' },
age: { type: 'number' },
verified: { type: 'boolean' },
location: { type: 'point' },
locationUpdated: { type: 'date' },
skills: { type: 'string[]' },
personalStatement: { type: 'text' }
})
/* use the client to create a Repository just for Persons */
export const personRepository = client.fetchRepository(personSchema)
/* create the index for Person */
await personRepository.createIndex()
Now, let's add some routes in Express.
Set up the Person Router
Let's create a truly RESTful API with the CRUD operations mapping to PUT, GET, POST, and DELETE respectively. We're going to do this using Express Routers as this makes our code nice and tidy. Create a file called person-router.js
in the routers
folder and in it import Router
from Express and personRepository
from person.js
. Then create and export a Router
:
import { Router } from 'express'
import { personRepository } from '../om/person.js'
export const router = Router()
Imports and exports done, let's bind the router to our Express app. Open up server.js
and import the Router
we just created:
/* import routers */
import { router as personRouter } from './routers/person-router.js'
Then add the personRouter
to the Express app:
/* bring in some routers */
app.use('/person', personRouter)
Your server.js
should now look like this:
import 'dotenv/config'
import express from 'express'
import swaggerUi from 'swagger-ui-express'
import YAML from 'yamljs'
/* import routers */
import { router as personRouter } from './routers/person-router.js'
/* create an express app and use JSON */
const app = new express()
app.use(express.json())
/* bring in some routers */
app.use('/person', personRouter)
/* set up swagger in the root */
const swaggerDocument = YAML.load('api.yaml')
app.use('/', swaggerUi.serve, swaggerUi.setup(swaggerDocument))
/* start the server */
app.listen(8080)
Now we can add our routes to create, read, update, and delete persons. Head back to the person-router.js
file so we can do just that.
Creating a Person
We'll create a person first as you need to have persons in Redis before you can do any of the reading, writing, or removing of them. Add the PUT route below. This route will call .createAndSave()
to create a Person
from the request body and immediately save it to the Redis:
router.put('/', async (req, res) => {
const person = await personRepository.createAndSave(req.body)
res.send(person)
})
Note that we are also returning the newly created Person
. Let's see what that looks like by actually calling our API using the Swagger UI. Go to http://localhost:8080 in your browser and try it out. The default request body in Swagger will be fine for testing. You should see a response that looks like this:
{
"entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN",
"firstName": "Rupert",
"lastName": "Holmes",
"age": 75,
"verified": false,
"location": {
"longitude": 45.678,
"latitude": 45.678
},
"locationUpdated": "2022-03-01T12:34:56.123Z",
"skills": [
"singing",
"songwriting",
"playwriting"
],
"personalStatement": "I like piña coladas and walks in the rain"
}
This is exactly what we handed it with one exception: the entityId
. Every entity in Redis OM has an entity ID which is—as you've probably guessed—the unique ID of that entity. It was randomly generated when we called .createAndSave()
. Yours will be different, so make note of it.
You can see this newly created JSON document in Redis with Redis Insight. Go ahead and launch Redis Insight and you should see a key with a name like Person:01FY9MWDTWW4XQNTPJ9XY9FPMN
. The Person
bit of the key was derived from the class name of our entity and the sequence of letters and numbers is our generated entity ID. Click on it to take a look at the JSON document you've created.
You'll also see a key named Person:index:hash
. That's a unique value that Redis OM uses to see if it needs to recreate the index or not when .createIndex()
is called. You can safely ignore it.
Reading a Person
Create down, let's add a GET route to read this newly created Person
:
router.get('/:id', async (req, res) => {
const person = await personRepository.fetch(req.params.id)
res.send(person)
})
This code extracts a parameter from the URL used in the route—the entityId
that we received previously. It uses the .fetch()
method on the personRepository
to retrieve a Person
using that entityId
. Then, it returns that Person
.
Let's go ahead and test that in Swagger as well. You should get back exactly the same response. In fact, since this is a simple GET, we should be able to just load the URL into our browser. Test that out too by navigating to http://localhost:8080/person/01FY9MWDTWW4XQNTPJ9XY9FPMN, replacing the entity ID with your own.
Now that we can read and write, let's implement the REST of the HTTP verbs. REST... get it?
Updating a Person
Let's add the code to update a person using a POST route:
router.post('/:id', async (req, res) => {
const person = await personRepository.fetch(req.params.id)
person.firstName = req.body.firstName ?? null
person.lastName = req.body.lastName ?? null
person.age = req.body.age ?? null
person.verified = req.body.verified ?? null
person.location = req.body.location ?? null
person.locationUpdated = req.body.locationUpdated ?? null
person.skills = req.body.skills ?? null
person.personalStatement = req.body.personalStatement ?? null
await personRepository.save(person)
res.send(person)
})
This code fetches the Person
from the personRepository
using the entityId
just like our previous route did. However, now we change all the properties based on the properties in the request body. If any of them are missing, we set them to null
. Then, we call .save()
and return the changed Person
.
Let's test this in Swagger too, why not? Make some changes. Try removing some of the fields. What do you get back when you read it after you've changed it?
Deleting a Person
Deletion—my favorite! Remember kids, deletion is 100% compression. The route that deletes is just as straightforward as the one that reads, but much more destructive:
router.delete('/:id', async (req, res) => {
await personRepository.remove(req.params.id)
res.send({ entityId: req.params.id })
})
I guess we should probably test this one out too. Load up Swagger and exercise the route. You should get back JSON with the entity ID you just removed:
{
"entityId": "01FY9MWDTWW4XQNTPJ9XY9FPMN"
}
And just like that, it's gone!
All the CRUD
Do a quick check with what you've written so far. Here's what should be the totality of your person-router.js
file:
import { Router } from 'express'
import { personRepository } from '../om/person.js'
export const router = Router()
router.put('/', async (req, res) => {
const person = await personRepository.createAndSave(req.body)
res.send(person)
})
router.get('/:id', async (req, res) => {
const person = await personRepository.fetch(req.params.id)
res.send(person)
})
router.post('/:id', async (req, res) => {
const person = await personRepository.fetch(req.params.id)
person.firstName = req.body.firstName ?? null
person.lastName = req.body.lastName ?? null
person.age = req.body.age ?? null
person.verified = req.body.verified ?? null
person.location = req.body.location ?? null
person.locationUpdated = req.body.locationUpdated ?? null
person.skills = req.body.skills ?? null
person.personalStatement = req.body.personalStatement ?? null
await personRepository.save(person)
res.send(person)
})
router.delete('/:id', async (req, res) => {
await personRepository.remove(req.params.id)
res.send({ entityId: req.params.id })
})
Preparing to search
CRUD completed, let's do some searching. In order to search, we need data to search over. Remember that persons
folder with all the JSON documents and the load-data.sh
shell script? Its time has arrived. Go into that folder and run the script:
cd persons
./load-data.sh
You should get a rather verbose response containing the JSON response from the API and the names of the files you loaded. Like this:
{"entityId":"01FY9Z4RRPKF4K9H78JQ3K3CP3","firstName":"Chris","lastName":"Stapleton","age":43,"verified":true,"location":{"longitude":-84.495,"latitude":38.03},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","football","coal mining"],"personalStatement":"There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."} <- chris-stapleton.json
{"entityId":"01FY9Z4RS2QQVN4XFYSNPKH6B2","firstName":"David","lastName":"Paich","age":67,"verified":false,"location":{"longitude":-118.25,"latitude":34.05},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","keyboard","blessing"],"personalStatement":"I seek to cure what's deep inside frightened of this thing that I've become"} <- david-paich.json
{"entityId":"01FY9Z4RSD7SQMSWDFZ6S4M5MJ","firstName":"Ivan","lastName":"Doroschuk","age":64,"verified":true,"location":{"longitude":-88.273,"latitude":40.115},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","friendship"],"personalStatement":"We can dance if we want to. We can leave your friends behind. 'Cause your friends don't dance and if they don't dance well they're no friends of mine."} <- ivan-doroschuk.json
{"entityId":"01FY9Z4RSRZFGQ21BMEKYHEVK6","firstName":"Joan","lastName":"Jett","age":63,"verified":false,"location":{"longitude":-75.273,"latitude":40.003},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","guitar","black eyeliner"],"personalStatement":"I love rock n' roll so put another dime in the jukebox, baby."} <- joan-jett.json
{"entityId":"01FY9Z4RT25ABWYTW6ZG7R79V4","firstName":"Justin","lastName":"Timberlake","age":41,"verified":true,"location":{"longitude":-89.971,"latitude":35.118},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","dancing","half-time shows"],"personalStatement":"What goes around comes all the way back around."} <- justin-timberlake.json
{"entityId":"01FY9Z4RTD9EKBDS2YN9CRMG1D","firstName":"Kerry","lastName":"Livgren","age":72,"verified":false,"location":{"longitude":-95.689,"latitude":39.056},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["poetry","philosophy","songwriting","guitar"],"personalStatement":"All we are is dust in the wind."} <- kerry-livgren.json
{"entityId":"01FY9Z4RTR73HZQXK83JP94NWR","firstName":"Marshal","lastName":"Mathers","age":49,"verified":false,"location":{"longitude":-83.046,"latitude":42.331},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["rapping","songwriting","comics"],"personalStatement":"Look, if you had, one shot, or one opportunity to seize everything you ever wanted, in one moment, would you capture it, or just let it slip?"} <- marshal-mathers.json
{"entityId":"01FY9Z4RV2QHH0Z1GJM5ND15JE","firstName":"Rupert","lastName":"Holmes","age":75,"verified":true,"location":{"longitude":-2.518,"latitude":53.259},"locationUpdated":"2022-01-01T12:00:00.000Z","skills":["singing","songwriting","playwriting"],"personalStatement":"I like piña coladas and taking walks in the rain."} <- rupert-holmes.json
A little messy, but if you don't see this, then it didn't work!
Now that we have some data, let's add another router to hold the search routes we want to add. Create a file named search-router.js
in the routers folder and set it up with imports and exports just like we did in person-router.js
:
import { Router } from 'express'
import { personRepository } from '../om/person.js'
export const router = Router()
Import the Router
into server.js
the same way we did for the personRouter
:
/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'
Then add the searchRouter
to the Express app:
/* bring in some routers */
app.use('/person', personRouter)
app.use('/persons', searchRouter)
Router bound, we can now add some routes.
Search all the things
We're going to add a plethora of searches to our new Router
. But the first will be the easiest as it's just going to return everything. Go ahead and add the following code to search-router.js
:
router.get('/all', async (req, res) => {
const persons = await personRepository.search().return.all()
res.send(persons)
})
Here we see how to start and finish a search. Searches start just like CRUD operations start—on a Repository
. But instead of calling .createAndSave()
, .fetch()
, .save()
, or .remove()
, we call .search()
. And unlike all those other methods, .search()
doesn't end there. Instead, it allows you to build up a query (which you'll see in the next example) and then resolve it with a call to .return.all()
.
With this new route in place, go into the Swagger UI and exercise the /persons/all
route. You should see all of the folks you added with the shell script as a JSON array.
In the example above, the query is not specified—we didn't build anything up. If you do this, you'll just get everything. Which is what you want sometimes. But not most of the time. It's not really searching if you just return everything. So let's add a route that lets us find persons by their last name. Add the following code:
router.get('/by-last-name/:lastName', async (req, res) => {
const lastName = req.params.lastName
const persons = await personRepository.search()
.where('lastName').equals(lastName).return.all()
res.send(persons)
})
In this route, we're specifying a field we want to filter on and a value that it needs to equal. The field name in the call to .where()
is the name of the field specified in our schema. This field was defined as a string
, which matters because the type of the field determines the methods that are available query it.
In the case of a string
, there's just .equals()
, which will query against the value of the entire string. This is aliased as .eq()
, .equal()
, and .equalTo()
for your convenience. You can even add a little more syntactic sugar with calls to .is
and .does
that really don't do anything but make your code pretty. Like this:
const persons = await personRepository.search().where('lastName').is.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.equal(lastName).return.all()
You can also invert the query with a call to .not
:
const persons = await personRepository.search().where('lastName').is.not.equalTo(lastName).return.all()
const persons = await personRepository.search().where('lastName').does.not.equal(lastName).return.all()
In all these cases, the call to .return.all()
executes the query we build between it and the call to .search()
. We can search on other field types as well. Let's add some routes to search on a number
and a boolean
field:
router.get('/old-enough-to-drink-in-america', async (req, res) => {
const persons = await personRepository.search()
.where('age').gte(21).return.all()
res.send(persons)
})
router.get('/non-verified', async (req, res) => {
const persons = await personRepository.search()
.where('verified').is.not.true().return.all()
res.send(persons)
})
The number
field is filtering persons by age where the age is great than or equal to 21. Again, there are aliases and syntactic sugar:
const persons = await personRepository.search().where('age').is.greaterThanOrEqualTo(21).return.all()
But there are also more ways to query:
const persons = await personRepository.search().where('age').eq(21).return.all()
const persons = await personRepository.search().where('age').gt(21).return.all()
const persons = await personRepository.search().where('age').gte(21).return.all()
const persons = await personRepository.search().where('age').lt(21).return.all()
const persons = await personRepository.search().where('age').lte(21).return.all()
const persons = await personRepository.search().where('age').between(21, 65).return.all()
The boolean
field is searching for persons by their verification status. It already has some of our syntactic sugar in it. Note that this query will match a missing value or a false value. That's why I specified .not.true()
. You can also call .false()
on boolean fields as well as all the variations of .equals
.
const persons = await personRepository.search().where('verified').true().return.all()
const persons = await personRepository.search().where('verified').false().return.all()
const persons = await personRepository.search().where('verified').equals(true).return.all()
So, we've created a few routes and I haven't told you to test them. Maybe you have anyhow. If so, good for you, you rebel. For the rest of you, why don't you go ahead and test them now with Swagger? And, going forward, just test them when you want. Heck, create some routes of your own using the provided syntax and try those out too. Don't let me tell you how to live your life.
Of course, querying on just one field is never enough. Not a problem, Redis OM can handle .and()
and .or()
like in this route:
router.get('/verified-drinkers-with-last-name/:lastName', async (req, res) => {
const lastName = req.params.lastName
const persons = await personRepository.search()
.where('verified').is.true()
.and('age').gte(21)
.and('lastName').equals(lastName).return.all()
res.send(persons)
})
Here, I'm just showing the syntax for .and()
but, of course, you can also use .or()
.
Full-text search
If you've defined a field with a type of text
in your schema, you can perform full-text searches against it. The way a text
field is searched is different from how a string
is searched. A string
can only be compared with .equals()
and must match the entire string. With a text
field, you can look for words within the string.
A text
field is optimized for human-readable text, like an essay or song lyrics. It's pretty clever. It understands that certain words (like a, an, or the) are common and ignores them. It understands how words are grammatically similar and so if you search for give, it matches gives, given, giving, and gave too. And it ignores punctuation.
Let's add a route that does full-text search against our personalStatement
field:
router.get('/with-statement-containing/:text', async (req, res) => {
const text = req.params.text
const persons = await personRepository.search()
.where('personalStatement').matches(text)
.return.all()
res.send(persons)
})
Note the use of the .matches()
function. This is the only one that works with text
fields. It takes a string that can be one or more words—space-delimited—that you want to query for. Let's try it out. In Swagger, use this route to search for the word "walk". You should get the following results:
[
{
"entityId": "01FYC7CTR027F219455PS76247",
"firstName": "Rupert",
"lastName": "Holmes",
"age": 75,
"verified": true,
"location": {
"longitude": -2.518,
"latitude": 53.259
},
"locationUpdated": "2022-01-01T12:00:00.000Z",
"skills": [
"singing",
"songwriting",
"playwriting"
],
"personalStatement": "I like piña coladas and taking walks in the rain."
},
{
"entityId": "01FYC7CTNBJD9CZKKWPQEZEW14",
"firstName": "Chris",
"lastName": "Stapleton",
"age": 43,
"verified": true,
"location": {
"longitude": -84.495,
"latitude": 38.03
},
"locationUpdated": "2022-01-01T12:00:00.000Z",
"skills": [
"singing",
"football",
"coal mining"
],
"personalStatement": "There are days that I can walk around like I'm alright. And I pretend to wear a smile on my face. And I could keep the pain from comin' out of my eyes. But sometimes, sometimes, sometimes I cry."
}
]
Notice how the word "walk" is matched for Rupert Holmes' personal statement that contains "walks" and matched for Chris Stapleton's that contains "walk". Now search "walk raining". You'll see that this returns Rupert's entry only even though the exact text of neither of these words is found in his personal statement. But they are grammatically related so it matched them. This is called stemming and it's a pretty cool feature of Redis Stack that Redis OM exploits.
And if you search for "a rain walk" you'll still match Rupert's entry even though the word "a" is not in the text. Why? Because it's a common word that's not very helpful with searching. These common words are called stop words and this is another cool feature of Redis Stack that Redis OM just gets for free.
Searching the globe
Redis Stack, and therefore Redis OM, both support searching by geographic location. You specify a point in the globe, a radius, and the units for that radius and it'll gleefully return all the entities therein. Let's add a route to do just that:
router.get('/near/:lng,:lat/radius/:radius', async (req, res) => {
const longitude = Number(req.params.lng)
const latitude = Number(req.params.lat)
const radius = Number(req.params.radius)
const persons = await personRepository.search()
.where('location')
.inRadius(circle => circle
.longitude(longitude)
.latitude(latitude)
.radius(radius)
.miles)
.return.all()
res.send(persons)
})
This code looks a little different than the others because the way we define the circle we want to search is done with a function that is passed into the .inRadius
method:
circle => circle.longitude(longitude).latitude(latitude).radius(radius).miles
All this function does is accept an instance of a Circle
that has been initialized with default values. We override those values by calling various builder methods to define the origin of our search (i.e. the longitude and latitude), the radius, and the units that radius is measured in. Valid units are miles
, meters
, feet
, and kilometers
.
Let's try the route out. I know we can find Joan Jett at around longitude -75.0 and latitude 40.0, which is in eastern Pennsylvania. So use those coordinates with a radius of 20 miles. You should receive in response:
[
{
"entityId": "01FYC7CTPKYNXQ98JSTBC37AS1",
"firstName": "Joan",
"lastName": "Jett",
"age": 63,
"verified": false,
"location": {
"longitude": -75.273,
"latitude": 40.003
},
"locationUpdated": "2022-01-01T12:00:00.000Z",
"skills": [
"singing",
"guitar",
"black eyeliner"
],
"personalStatement": "I love rock n' roll so put another dime in the jukebox, baby."
}
]
Try widening the radius and see who else you can find.
Adding location tracking
We're getting toward the end of the tutorial here, but before we go, I'd like to add that location tracking piece that I mentioned way back in the beginning. This next bit of code should be easily understood if you've gotten this far as it's not really doing anything I haven't talked about already.
Add a new file called location-router.js
in the routers
folder:
import { Router } from 'express'
import { personRepository } from '../om/person.js'
export const router = Router()
router.patch('/:id/location/:lng,:lat', async (req, res) => {
const id = req.params.id
const longitude = Number(req.params.lng)
const latitude = Number(req.params.lat)
const locationUpdated = new Date()
const person = await personRepository.fetch(id)
person.location = { longitude, latitude }
person.locationUpdated = locationUpdated
await personRepository.save(person)
res.send({ id, locationUpdated, location: { longitude, latitude } })
})
Here we're calling .fetch()
to fetch a person, we're updating some values for that person—the .location
property with our longitude and latitude and the .locationUpdated
property with the current date and time. Easy stuff.
To use this Router
, import it in server.js
:
/* import routers */
import { router as personRouter } from './routers/person-router.js'
import { router as searchRouter } from './routers/search-router.js'
import { router as locationRouter } from './routers/location-router.js'
And bind the router to a path:
/* bring in some routers */
app.use('/person', personRouter, locationRouter)
app.use('/persons', searchRouter)
And that's that. But this just isn't enough to satisfy. It doesn't show you anything new, except maybe the usage of a date
field. And, it's not really location tracking. It just shows where these people last were, no history. So let's add some!.
To add some history, we're going to use a Redis Stream. Streams are a big topic but don't worry if you’re not familiar with them, you can think of them as being sort of like a log file stored in a Redis key where each entry represents an event. In our case, the event would be the person moving about or checking in or whatever.
But there's a problem. Redis OM doesn’t support Streams even though Redis Stack does. So how do we take advantage of them in our application? By using Node Redis. Node Redis is a low-level Redis client for Node.js that gives you access to all the Redis commands and data types. Internally, Redis OM is creating and using a Node Redis connection. You can use that connection too. Or rather, Redis OM can be told to use the connection you are using. Let me show you how.
Using Node Redis
Open up client.js
in the om
folder. Remember how we created a Redis OM Client
and then called .open()
on it?
const client = await new Client().open(url)
Well, the Client
class also has a .use()
method that takes a Node Redis connection. Modify client.js
to open a connection to Redis using Node Redis and then .use()
it:
import { Client } from 'redis-om'
import { createClient } from 'redis'
/* pulls the Redis URL from .env */
const url = process.env.REDIS_URL
/* create a connection to Redis with Node Redis */
export const connection = createClient({ url })
await connection.connect()
/* create a Client and bind it to the Node Redis connection */
const client = await new Client().use(connection)
export default client
And that's it. Redis OM is now using the connection
you created. Note that we are exporting both the client
and the connection
. Got to export the connection
if we want to use it in our newest route.
Storing location history with Streams
To add an event to a Stream we need to use the XADD command. Node Redis exposes that as .xAdd()
. So, we need to add a call to .xAdd()
in our route. Modify location-router.js
to import our connection
:
import { connection } from '../om/client.js'
And then in the route itself add a call to .xAdd()
:
...snip...
const person = await personRepository.fetch(id)
person.location = { longitude, latitude }
person.locationUpdated = locationUpdated
await personRepository.save(person)
let keyName = `${person.keyName}:locationHistory`
await connection.xAdd(keyName, '*', person.location)
...snip...
.xAdd()
takes a key name, an event ID, and a JavaScript object containing the keys and values that make up the event, i.e. the event data. For the key name, we're building a string using the .keyName
property that Person
inherited from Entity
(which will return something like Person:01FYC7CTPKYNXQ98JSTBC37AS1
) combined with a hard-coded value. We're passing in *
for our event ID, which tells Redis to just generate it based on the current time and previous event ID. And we're passing in the location—with properties of longitude and latitude—as our event data.
Now, whenever this route is exercised, the longitude and latitude will be logged and the event ID will encode the time. Go ahead and use Swagger to move Joan Jett around a few times.
Now, go into Redis Insight and take a look at the Stream. You'll see it there in the list of keys but if you click on it, you'll get a message saying that "This data type is coming soon!". If you don't get this message, congratulations, you live in the future! For us here in the past, we'll just issue the raw command instead:
XRANGE Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory - +
This tells Redis to get a range of values from a Stream stored in the given the key name—Person:01FYC7CTPKYNXQ98JSTBC37AS1:locationHistory
in our example. The next values are the starting event ID and the ending event ID. -
is the beginning of the Stream. +
is the end. So this returns everything in the Stream:
1) 1) "1647536562911-0"
2) 1) "longitude"
2) "45.678"
3) "latitude"
4) "45.678"
2) 1) "1647536564189-0"
2) 1) "longitude"
2) "45.679"
3) "latitude"
4) "45.679"
3) 1) "1647536565278-0"
2) 1) "longitude"
2) "45.680"
3) "latitude"
4) "45.680"
And just like that, we're tracking Joan Jett.
Wrap-up
So, now you know how to use Express + Redis OM to build an API backed by Redis Stack. And, you've got yourself some pretty decent started code in the process. Good deal! If you want to learn more, you can check out the documentation for Redis OM. It covers the full breadth of Redis OM's features.
And thanks for taking the time to work through this. I sincerely hope you found it useful. If you have any questions, the Redis Discord server is by far the best place to get them answered. Join the server and ask away!