├── .gitignore ├── LICENSE ├── README.md ├── app.py ├── data └── people.json ├── dataloader.py ├── docker-compose.yml ├── person.py ├── requirements.txt └── screenshots ├── insight_explore_person.png └── server_running.png /.gitignore: -------------------------------------------------------------------------------- 1 | *.bak 2 | *.swp 3 | *.tmp 4 | *.rdb 5 | .DS_Store 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # C extensions 13 | *.so 14 | 15 | # Distribution / packaging 16 | .Python 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | downloads/ 21 | eggs/ 22 | .eggs/ 23 | lib/ 24 | lib64/ 25 | parts/ 26 | sdist/ 27 | var/ 28 | wheels/ 29 | pip-wheel-metadata/ 30 | share/python-wheels/ 31 | *.egg-info/ 32 | .installed.cfg 33 | *.egg 34 | MANIFEST 35 | 36 | # PyInstaller 37 | # Usually these files are written by a python script from a template 38 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 39 | *.manifest 40 | *.spec 41 | 42 | # Installer logs 43 | pip-log.txt 44 | pip-delete-this-directory.txt 45 | 46 | # Unit test / coverage reports 47 | htmlcov/ 48 | .tox/ 49 | .nox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | nosetests.xml 54 | coverage.xml 55 | *.cover 56 | *.py,cover 57 | .hypothesis/ 58 | .pytest_cache/ 59 | 60 | # Translations 61 | *.mo 62 | *.pot 63 | 64 | # Django stuff: 65 | *.log 66 | local_settings.py 67 | db.sqlite3 68 | db.sqlite3-journal 69 | 70 | # Flask stuff: 71 | instance/ 72 | .webassets-cache 73 | 74 | # Scrapy stuff: 75 | .scrapy 76 | 77 | # Sphinx documentation 78 | docs/_build/ 79 | 80 | # PyBuilder 81 | target/ 82 | 83 | # Jupyter Notebook 84 | .ipynb_checkpoints 85 | 86 | # IPython 87 | profile_default/ 88 | ipython_config.py 89 | 90 | # pyenv 91 | .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 101 | __pypackages__/ 102 | 103 | # Celery stuff 104 | celerybeat-schedule 105 | celerybeat.pid 106 | 107 | # SageMath parsed files 108 | *.sage.py 109 | 110 | # Environments 111 | .env 112 | .venv 113 | env/ 114 | venv/ 115 | ENV/ 116 | env.bak/ 117 | venv.bak/ 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Redis Developer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redis OM Python Flask Starter Application 2 | 3 | A starter application for performing CRUD type operations with Redis OM Python ([GitHub](https://github.com/redis/redis-om-python), [Blog Post](https://redis.com/blog/introducing-redis-om-for-python/)), [Redis Stack](https://redis.io/docs/stack/) and the [Flask](https://flask.palletsprojects.com/) microframework. 4 | 5 | We'd love to see what you build with Redis Stack and Redis OM. [Join the Redis community on Discord](https://discord.gg/redis) to chat with us about all things Redis OM and Redis Stack. 6 | 7 | [Watch the workshop video](https://www.youtube.com/watch?v=PPT1FElAS84) that accompanies this application on YouTube. 8 | 9 | ## Overview 10 | 11 | This application demonstrates common data manipulation patterns using Redis OM, an API built with Flask and a simple domain model. 12 | 13 | Our entity is a Person, with the following JSON representation: 14 | 15 | ```json 16 | { 17 | "first_name": "A string, the person's first or given name", 18 | "last_name": "A string, the person's last or surname", 19 | "age": 36, 20 | "address": { 21 | "street_number": 56, 22 | "unit": "A string, optional unit number e.g. A or 1", 23 | "street_name": "A string, name of the street they live on", 24 | "city": "A string, name of the city they live in", 25 | "state": "A string, state, province or county that they live in", 26 | "postal_code": "A string, their zip or postal code", 27 | "country": "A string, country that they live in." 28 | }, 29 | "personal_statement": "A string, free text personal statement", 30 | "skills": [ 31 | "A string: a skill the person has", 32 | "A string: another still that the person has" 33 | ] 34 | } 35 | ``` 36 | 37 | We'll let Redis OM handle generation of unique IDs, which it does using [ULIDs](https://github.com/ulid/spec). Redis OM will also handle creation of unique Redis key names for us, as well as saving and retrieving entities from JSON documents stored in a Redis Stack database. 38 | 39 | ## Getting Started 40 | 41 | Let's go... 42 | 43 | ### Requirements 44 | 45 | To run this application you'll need: 46 | 47 | * [git](https://git-scm.com/download) - to clone the repo to your machine. 48 | * [Python 3.9 or higher](https://www.python.org/downloads/). 49 | * A [Redis Stack](https://redis.io) database, or Redis with the [RediSearch](https://redisearch.io) and [RedisJSON](https://redisjson.io) modules installed. We've provided a `docker-compose.yml` for this. You can also [sign up for a free 30Mb database with Redis Enterprise Cloud](https://redis.com/try-free/) - be sure to check the Redis Stack option when creating your cloud database. 50 | * [curl](https://curl.se/), or [Postman](https://www.postman.com/) - to send HTTP requests to the application. We'll provide examples using curl in this document. 51 | * Optional: [RedisInsight](https://redis.com/redis-enterprise/redis-insight/), a free data visualization and database management tool for Redis. When downloading RedisInsight, be sure to select version 2.x or use the version that comes with Redis Stack. 52 | 53 | ### Get the Source Code 54 | 55 | Clone the repository from GitHub: 56 | 57 | ```bash 58 | $ git clone https://github.com/redis-developer/redis-om-python-flask-skeleton-app.git 59 | $ cd redis-om-python-flask-skeleton-app 60 | ``` 61 | 62 | ### Start a Redis Stack Database, or Configure your Redis Enterprise Cloud Credentials 63 | 64 | Next, we'll get a Redis Stack database up and running. If you're using Docker: 65 | 66 | ```bash 67 | $ docker-compose up -d 68 | Creating network "redis-om-python-flask-skeleton-app_default" with the default driver 69 | Creating redis_om_python_flask_starter ... done 70 | ``` 71 | 72 | If you're using Redis Enterprise Cloud, you'll need the hostname, port number, and password for your database. Use these to set the `REDIS_OM_URL` environment variable like this: 73 | 74 | ```bash 75 | $ export REDIS_OM_URL=redis://default:@: 76 | ``` 77 | 78 | (This step is not required when working with Docker as the Docker container runs Redis on `localhost` port `6379` with no password, which is the default connection that Redis OM uses.) 79 | 80 | For example if your Redis Enterprise Cloud database is at port `9139` on host `enterprise.redis.com` and your password is `5uper53cret` then you'd set `REDIS_OM_URL` as follows: 81 | 82 | ```bash 83 | $ export REDIS_OM_URL=redis://default:5uper53cret@enterprise.redis.com:9139 84 | ``` 85 | 86 | ### Create a Python Virtual Environment and Install the Dependencies 87 | 88 | Create a Python virtual environment, and install the project dependencies which are [Flask](https://pypi.org/project/Flask/), [Requests](https://pypi.org/project/requests/) (used only in the data loader script) and [Redis OM](https://pypi.org/project/redis-om/): 89 | 90 | ```bash 91 | $ python3 -m venv venv 92 | $ . ./venv/bin/activate 93 | $ pip install -r requirements.txt 94 | ``` 95 | 96 | ### Start the Flask Application 97 | 98 | Let's start the Flask application in development mode, so that Flask will restart the server for you each time you save code changes in `app.py`: 99 | 100 | ```bash 101 | $ export FLASK_ENV=development 102 | $ flask run 103 | ``` 104 | 105 | If all goes well, you should see output similar to this: 106 | 107 | ```bash 108 | $ flask run 109 | * Environment: development 110 | * Debug mode: on 111 | * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) 112 | * Restarting with stat 113 | * Debugger is active! 114 | * Debugger PIN: XXX-XXX-XXX 115 | ``` 116 | 117 | You're now up and running, and ready to perform CRUD operations on data with Redis, RediSearch, RedisJSON and Redis OM for Python! To make sure the server's running, point your browser at `http://127.0.0.1:5000/`, where you can expect to see the application's basic home page: 118 | 119 | ![screenshot](screenshots/server_running.png) 120 | 121 | ### Load the Sample Data 122 | 123 | We've provided a small amount of sample data (it's in `data/people.json`. The Python script `dataloader.py` loads each person into Redis by posting the data to the application's create a new person endpoint. Run it like this: 124 | 125 | ```bash 126 | $ python dataloader.py 127 | Created person Robert McDonald with ID 01FX8RMR7NRS45PBT3XP9KNAZH 128 | Created person Kareem Khan with ID 01FX8RMR7T60ANQTS4P9NKPKX8 129 | Created person Fernando Ortega with ID 01FX8RMR7YB283BPZ88HAG066P 130 | Created person Noor Vasan with ID 01FX8RMR82D091TC37B45RCWY3 131 | Created person Dan Harris with ID 01FX8RMR8545RWW4DYCE5MSZA1 132 | ``` 133 | 134 | Make sure to take a copy of the output of the data loader, as your IDs will differ from those used in the tutorial. To follow along, substitute your IDs for the ones shown above. e.g. whenever we are working with Kareem Khan, change `01FX8RMR7T60ANQTS4P9NKPKX8` for the ID that your data loader assiged to Kareem in your Redis database. 135 | 136 | ### Problems? 137 | 138 | If the Flask server fails to start, take a look at its output. If you see log entries similar to this: 139 | 140 | ```python 141 | raise ConnectionError(self._error_message(e)) 142 | redis.exceptions.ConnectionError: Error 61 connecting to localhost:6379. Connection refused. 143 | ``` 144 | 145 | then you need to start the Redis Docker container if using Docker, or set the `REDIS_OM_URL` environment variable if using Redis Enterprise Cloud. 146 | 147 | If you've set the `REDIS_OM_URL` environment variable, and the code errors with something like this on startup: 148 | 149 | ```python 150 | raise ConnectionError(self._error_message(e)) 151 | redis.exceptions.ConnectionError: Error 8 connecting to enterprise.redis.com:9139. nodename nor servname provided, or not known. 152 | ``` 153 | 154 | then you'll need to check that you used the correct hostname, port, password and format when setting `REDIS_OM_URL`. 155 | 156 | If the data loader fails to post the sample data into the application, make sure that the Flask application is running **before** running the data loader. 157 | 158 | ## Create, Read, Update and Delete Data 159 | 160 | Let's create and manipulate some instances of our data model in Redis. Here we'll look at how to call the Flask API with curl (you could also use Postman), how the code works, and how the data's stored in Redis. 161 | 162 | ### Building a Person Model with Redis OM 163 | 164 | Redis OM allows us to model entities using Python classes, and the [Pydantic](https://pypi.org/project/pydantic/) framework. Our person model is contained in the file `person.py`. Here's some notes about how it works: 165 | 166 | * We declare a class `Person` which extends a Redis OM class `JsonModel`. This tells Redis OM that we want to store these entities in Redis as JSON documents. 167 | * We then declare each field in our model, specifying the data type and whether or not we want to index on that field. For example, here's the `age` field, which we've declared as a positive integer that we want to index on: 168 | 169 | ```python 170 | age: PositiveInt = Field(index=True) 171 | ``` 172 | 173 | * The `skills` field is a list of strings, declared thus: 174 | 175 | ```python 176 | skills: List[str] = Field(index=True) 177 | ``` 178 | 179 | * For the `personal_statement` field, we don't want to index on the field's value, as it's a free text sentence rather than a single word or digit. For this, we'll tell Redis OM that we want to be able to perform full text searches on the values: 180 | 181 | ```python 182 | personal_statement: str = Field(index=True, full_text_search=True) 183 | ``` 184 | 185 | * `address` works differently from the other fields. Note that in our JSON representation of the model, address is an object rather than a string or numerical field. With Redis OM, this is modeled as a second class, which extends the Redis OM `EmbeddedJsonModel` class: 186 | 187 | ```python 188 | class Address(EmbeddedJsonModel): 189 | # field definitions... 190 | ``` 191 | 192 | * Fields in an `EmbeddedJsonModel` are defined in the same way, so our class contains a field definition for each data item in the address. 193 | 194 | * Not every field in our JSON is present in every address, Redis OM allows us to declare a field as optional so long as we don't index it: 195 | 196 | ```python 197 | unit: Optional[str] = Field(index=False) 198 | ``` 199 | 200 | * We can also set a default value for a field... let's say country should be "United Kingdom" unless otherwise specified: 201 | 202 | ```python 203 | country: str = Field(index=True, default="United Kingdom") 204 | ``` 205 | * Finally, to add the embedded address object to our Person model, we declare a field of type `Address` in the Person class: 206 | 207 | ```python 208 | address: Address 209 | ``` 210 | 211 | ### Adding New People 212 | 213 | The function `create_person` in `app.py` handles the creation of a new person in Redis. It expects a JSON object that adheres to our Person model's schema. The code to then create a new Person object with that data and save it in Redis is simple: 214 | 215 | ```python 216 | new_person = Person(**request.json) 217 | new_person.save() 218 | return new_person.pk 219 | ``` 220 | 221 | When a new Person instance is created, Redis OM assigns it a unique ULID primary key, which we can access as `.pk`. We return that to the caller, so that they know the ID of the object they just created. 222 | 223 | Persisting the object to Redis is then simply a matter of calling `.save()` on it. 224 | 225 | Try it out... with the server running, add a new person using curl: 226 | 227 | ```bash 228 | curl --location --request POST 'http://127.0.0.1:5000/person/new' \ 229 | --header 'Content-Type: application/json' \ 230 | --data-raw '{ 231 | "first_name": "Joanne", 232 | "last_name": "Peel", 233 | "age": 36, 234 | "personal_statement": "Music is my life, I love gigging and playing with my band.", 235 | "address": { 236 | "street_number": 56, 237 | "unit": "4A", 238 | "street_name": "The Rushes", 239 | "city": "Birmingham", 240 | "state": "West Midlands", 241 | "postal_code": "B91 6HG", 242 | "country": "United Kingdom" 243 | }, 244 | "skills": [ 245 | "synths", 246 | "vocals", 247 | "guitar" 248 | ] 249 | }' 250 | ``` 251 | 252 | Running the above curl command will return the unique ULID ID assigned to the newly created person. For example `01FX8SSSDN7PT9T3N0JZZA758G`. 253 | 254 | ### Examining the data in Redis 255 | 256 | Let's take a look at what we just saved in Redis. Using RedisInsight or redis-cli, connect to the database and look at the value stored at key `:person.Person:01FX8SSSDN7PT9T3N0JZZA758G`. This is stored as a JSON document in Redis, so if using redis-cli you'll need the following command: 257 | 258 | ```bash 259 | $ redis-cli 260 | 127.0.0.1:6379> json.get :person.Person:01FX8SSSDN7PT9T3N0JZZA758G 261 | ``` 262 | 263 | If you're using RedisInsight, the browser will render the key value for you when you click on the key name: 264 | 265 | ![Data in RedisInsight](screenshots/insight_explore_person.png) 266 | 267 | When storing data as JSON in Redis, we can update and retrieve the whole document, or just parts of it. For example, to retrieve only the person's address and first skill, use the following command (RedisInsight users should use the built in redis-cli for this): 268 | 269 | ```bash 270 | $ redis-cli 271 | 127.0.0.1:6379> json.get :person.Person:01FX8SSSDN7PT9T3N0JZZA758G $.address $.skills[0] 272 | "{\"$.skills[0]\":[\"synths\"],\"$.address\":[{\"pk\":\"01FX8SSSDNRDSRB3HMVH00NQTT\",\"street_number\":56,\"unit\":\"4A\",\"street_name\":\"The Rushes\",\"city\":\"Birmingham\",\"state\":\"West Midlands\",\"postal_code\":\"B91 6HG\",\"country\":\"United Kingdom\"}]}" 273 | ``` 274 | 275 | For more information on the JSON Path syntax used to query JSON documents in Redis, see the [RedisJSON documentation](https://oss.redis.com/redisjson/path/). 276 | 277 | ### Find a Person by ID 278 | 279 | If we know a person's ID, we can retrieve their data. The function `find_by_id` in `app.py` receives an ID as its parameter, and asks Redis OM to retrieve and populate a Person object using the ID and the Person `.get` class method: 280 | 281 | ```python 282 | try: 283 | person = Person.get(id) 284 | return person.dict() 285 | except NotFoundError: 286 | return {} 287 | ``` 288 | 289 | The `.dict()` method converts our Person object to a Python dictionary that Flask then returns to the caller. 290 | 291 | Note that if there is no Person with the supplied ID in Redis, `get` will throw a `NotFoundError`. 292 | 293 | Try this out with curl, substituting `01FX8SSSDN7PT9T3N0JZZA758G` for the ID of a person that you just created in your database: 294 | 295 | ```bash 296 | curl --location --request GET 'http://localhost:5000/person/byid/01FX8SSSDN7PT9T3N0JZZA758G' 297 | ``` 298 | 299 | The server responds with a JSON object containing the user's data: 300 | 301 | ```json 302 | { 303 | "address": { 304 | "city": "Birmingham", 305 | "country": "United Kingdom", 306 | "pk": "01FX8SSSDNRDSRB3HMVH00NQTT", 307 | "postal_code": "B91 6HG", 308 | "state": "West Midlands", 309 | "street_name": "The Rushes", 310 | "street_number": 56, 311 | "unit": null 312 | }, 313 | "age": 36, 314 | "first_name": "Joanne", 315 | "last_name": "Peel", 316 | "personal_statement": "Music is my life, I love gigging and playing with my band.", 317 | "pk": "01FX8SSSDN7PT9T3N0JZZA758G", 318 | "skills": [ 319 | "synths", 320 | "vocals", 321 | "guitar" 322 | ] 323 | } 324 | ``` 325 | 326 | ### Find People with Matching First and Last Name 327 | 328 | Let's find all the people who have a given first and last name... This is handled by the function `find_by_name` in `app.py`. 329 | 330 | Here, we're using Person's `find` class method that's provided by Redis OM. We pass it a search query, specifying that we want to find people whose `first_name` field contains the value of the `first_name` parameter passed to `find_by_name` AND whose `last_name` field contains the value of the `last_name` parameter: 331 | 332 | ```python 333 | people = Person.find( 334 | (Person.first_name == first_name) & 335 | (Person.last_name == last_name) 336 | ).all() 337 | ``` 338 | 339 | `.all()` tells Redis OM that we want to retrieve all matching people. 340 | 341 | Try this out with curl as follows: 342 | 343 | ```bash 344 | curl --location --request GET 'http://127.0.0.1:5000/people/byname/Kareem/Khan' 345 | ``` 346 | 347 | **Note:** First and last name are case sensitive. 348 | 349 | The server responds with an object containing `results`, an array of matches: 350 | 351 | ```json 352 | { 353 | "results": [ 354 | { 355 | "address": { 356 | "city": "Sheffield", 357 | "country": "United Kingdom", 358 | "pk": "01FX8RMR7THMGA84RH8ZRQRRP9", 359 | "postal_code": "S1 5RE", 360 | "state": "South Yorkshire", 361 | "street_name": "The Beltway", 362 | "street_number": 1, 363 | "unit": "A" 364 | }, 365 | "age": 27, 366 | "first_name": "Kareem", 367 | "last_name": "Khan", 368 | "personal_statement":"I'm Kareem, a multi-instrumentalist and singer looking to join a new rock band.", 369 | "pk":"01FX8RMR7T60ANQTS4P9NKPKX8", 370 | "skills": [ 371 | "drums", 372 | "guitar", 373 | "synths" 374 | ] 375 | } 376 | ] 377 | } 378 | ``` 379 | 380 | ### Find People within a Given Age Range 381 | 382 | It's useful to be able to find people that fall into a given age range... the function `find_in_age_range` in `app.py` handles this as follows... 383 | 384 | We'll again use Person's `find` class method, this time passing it a minimum and maximum age, specifying that we want results where the `age` field is between those values only: 385 | 386 | ```python 387 | people = Person.find( 388 | (Person.age >= min_age) & 389 | (Person.age <= max_age) 390 | ).sort_by("age").all() 391 | ``` 392 | 393 | Note that we can also use `.sort_by` to specify which field we want our results sorted by. 394 | 395 | Let's find everyone between 30 and 47 years old, sorted by age: 396 | 397 | ```bash 398 | curl --location --request GET 'http://127.0.0.1:5000/people/byage/30/47' 399 | ``` 400 | 401 | This returns a `results` object containing an array of matches: 402 | 403 | ```json 404 | { 405 | "results": [ 406 | { 407 | "address": { 408 | "city": "Sheffield", 409 | "country": "United Kingdom", 410 | "pk": "01FX8RMR7NW221STN6NVRDPEDT", 411 | "postal_code": "S12 2MX", 412 | "state": "South Yorkshire", 413 | "street_name": "Main Street", 414 | "street_number": 9, 415 | "unit": null 416 | }, 417 | "age": 35, 418 | "first_name": "Robert", 419 | "last_name": "McDonald", 420 | "personal_statement": "My name is Robert, I love meeting new people and enjoy music, coding and walking my dog.", 421 | "pk": "01FX8RMR7NRS45PBT3XP9KNAZH", 422 | "skills": [ 423 | "guitar", 424 | "piano", 425 | "trombone" 426 | ] 427 | }, 428 | { 429 | "address": { 430 | "city": "Birmingham", 431 | "country": "United Kingdom", 432 | "pk": "01FX8SSSDNRDSRB3HMVH00NQTT", 433 | "postal_code": "B91 6HG", 434 | "state": "West Midlands", 435 | "street_name": "The Rushes", 436 | "street_number": 56, 437 | "unit": null 438 | }, 439 | "age": 36, 440 | "first_name": "Joanne", 441 | "last_name": "Peel", 442 | "personal_statement": "Music is my life, I love gigging and playing with my band.", 443 | "pk": "01FX8SSSDN7PT9T3N0JZZA758G", 444 | "skills": [ 445 | "synths", 446 | "vocals", 447 | "guitar" 448 | ] 449 | }, 450 | { 451 | "address": { 452 | "city": "Nottingham", 453 | "country": "United Kingdom", 454 | "pk": "01FX8RMR82DDJ90CW8D1GM68YZ", 455 | "postal_code": "NG1 1AA", 456 | "state": "Nottinghamshire", 457 | "street_name": "Broadway", 458 | "street_number": 12, 459 | "unit": "A-1" 460 | }, 461 | "age": 37, 462 | "first_name": "Noor", 463 | "last_name": "Vasan", 464 | "personal_statement": "I sing and play the guitar, I enjoy touring and meeting new people on the road.", 465 | "pk": "01FX8RMR82D091TC37B45RCWY3", 466 | "skills": [ 467 | "vocals", 468 | "guitar" 469 | ] 470 | }, 471 | { 472 | "address": { 473 | "city": "San Diego", 474 | "country": "United States", 475 | "pk": "01FX8RMR7YCDAVSWBMWCH2B07G", 476 | "postal_code": "92102", 477 | "state": "California", 478 | "street_name": "C Street", 479 | "street_number": 1299, 480 | "unit": null 481 | }, 482 | "age": 43, 483 | "first_name": "Fernando", 484 | "last_name": "Ortega", 485 | "personal_statement": "I'm in a really cool band that plays a lot of cover songs. I'm the drummer!", 486 | "pk": "01FX8RMR7YB283BPZ88HAG066P", 487 | "skills": [ 488 | "clarinet", 489 | "oboe", 490 | "drums" 491 | ] 492 | } 493 | ] 494 | } 495 | ``` 496 | 497 | ### Find People in a Given City with a Specific Skill 498 | 499 | Now, we'll try a slightly different sort of query. We want to find all of the people that live in a given city AND who also have a certain skill. This requires a search over both the `city` field which is a string, and the `skills` field, which is an array of strings. 500 | 501 | Essentially we want to say "Find me all the people whose city is `city` AND whose skills array CONTAINS `desired_skill`", where `city` and `desired_skill` are the parameters to the `find_matching_skill` function in `app.py`. Here's the code for that: 502 | 503 | ```python 504 | people = Person.find( 505 | (Person.skills << desired_skill) & 506 | (Person.address.city == city) 507 | ).all() 508 | ``` 509 | 510 | The `<<` operator here is used to indicate "in" or "contains". 511 | 512 | Let's find all the guitar players in Sheffield: 513 | 514 | ```bash 515 | curl --location --request GET 'http://127.0.0.1:5000/people/byskill/guitar/Sheffield' 516 | ``` 517 | 518 | **Note:** `Sheffield` is case sensitive. 519 | 520 | The server returns a `results` array containing matching people: 521 | 522 | ```json 523 | { 524 | "results": [ 525 | { 526 | "address": { 527 | "city": "Sheffield", 528 | "country": "United Kingdom", 529 | "pk": "01FX8RMR7THMGA84RH8ZRQRRP9", 530 | "postal_code": "S1 5RE", 531 | "state": "South Yorkshire", 532 | "street_name": "The Beltway", 533 | "street_number": 1, 534 | "unit": "A" 535 | }, 536 | "age": 28, 537 | "first_name": "Kareem", 538 | "last_name": "Khan", 539 | "personal_statement": "I'm Kareem, a multi-instrumentalist and singer looking to join a new rock band.", 540 | "pk": "01FX8RMR7T60ANQTS4P9NKPKX8", 541 | "skills": [ 542 | "drums", 543 | "guitar", 544 | "synths" 545 | ] 546 | }, 547 | { 548 | "address": { 549 | "city": "Sheffield", 550 | "country": "United Kingdom", 551 | "pk": "01FX8RMR7NW221STN6NVRDPEDT", 552 | "postal_code": "S12 2MX", 553 | "state": "South Yorkshire", 554 | "street_name": "Main Street", 555 | "street_number": 9, 556 | "unit": null 557 | }, 558 | "age": 35, 559 | "first_name": "Robert", 560 | "last_name": "McDonald", 561 | "personal_statement": "My name is Robert, I love meeting new people and enjoy music, coding and walking my dog.", 562 | "pk": "01FX8RMR7NRS45PBT3XP9KNAZH", 563 | "skills": [ 564 | "guitar", 565 | "piano", 566 | "trombone" 567 | ] 568 | } 569 | ] 570 | } 571 | ``` 572 | 573 | ### Find People using Full Text Search on their Personal Statements 574 | 575 | Each person has a `personal_statement` field, which is a free text string containing a couple of sentences about them. We chose to index this in a way that makes it full text searchable, so let's see how to use this now. The code for this is in the function `find_matching_statements` in `app.py`. 576 | 577 | To search for people who have the value of the parameter `search_term` in their `personal_statement` field, we use the `%` operator: 578 | 579 | ```python 580 | Person.find(Person.personal_statement % search_term).all() 581 | ``` 582 | 583 | Let's find everyone who talks about "play" in their personal statement. 584 | 585 | ```bash 586 | curl --location --request GET 'http://127.0.0.1:5000/people/bystatement/play' 587 | ``` 588 | 589 | The server responds with a `results` array of matching people: 590 | 591 | ```json 592 | { 593 | "results": [ 594 | { 595 | "address": { 596 | "city": "San Diego", 597 | "country": "United States", 598 | "pk": "01FX8RMR7YCDAVSWBMWCH2B07G", 599 | "postal_code": "92102", 600 | "state": "California", 601 | "street_name": "C Street", 602 | "street_number": 1299, 603 | "unit": null 604 | }, 605 | "age": 43, 606 | "first_name": "Fernando", 607 | "last_name": "Ortega", 608 | "personal_statement": "I'm in a really cool band that plays a lot of cover songs. I'm the drummer!", 609 | "pk": "01FX8RMR7YB283BPZ88HAG066P", 610 | "skills": [ 611 | "clarinet", 612 | "oboe", 613 | "drums" 614 | ] 615 | }, { 616 | "address": { 617 | "city": "Nottingham", 618 | "country": "United Kingdom", 619 | "pk": "01FX8RMR82DDJ90CW8D1GM68YZ", 620 | "postal_code": "NG1 1AA", 621 | "state": "Nottinghamshire", 622 | "street_name": "Broadway", 623 | "street_number": 12, 624 | "unit": "A-1" 625 | }, 626 | "age": 37, 627 | "first_name": "Noor", 628 | "last_name": "Vasan", 629 | "personal_statement": "I sing and play the guitar, I enjoy touring and meeting new people on the road.", 630 | "pk": "01FX8RMR82D091TC37B45RCWY3", 631 | "skills": [ 632 | "vocals", 633 | "guitar" 634 | ] 635 | }, 636 | { 637 | "address": { 638 | "city": "Birmingham", 639 | "country": "United Kingdom", 640 | "pk": "01FX8SSSDNRDSRB3HMVH00NQTT", 641 | "postal_code": "B91 6HG", 642 | "state": "West Midlands", 643 | "street_name": "The Rushes", 644 | "street_number": 56, 645 | "unit": null 646 | }, 647 | "age": 36, 648 | "first_name": "Joanne", 649 | "last_name": "Peel", 650 | "personal_statement": "Music is my life, I love gigging and playing with my band.", 651 | "pk": "01FX8SSSDN7PT9T3N0JZZA758G", 652 | "skills": [ 653 | "synths", 654 | "vocals", 655 | "guitar" 656 | ] 657 | } 658 | ] 659 | } 660 | ``` 661 | 662 | Note that we get results including matches for "play", "plays" and "playing". 663 | 664 | ### Update a Person's Age 665 | 666 | As well as retrieving information from Redis, we'll also want to update a Person's data from time to time. Let's see how to do that with Redis OM for Python. 667 | 668 | The function `update_age` in `app.py` accepts two parameters: `id` and `new_age`. Using these, we first retrieve the person's data from Redis and create a new object with it: 669 | 670 | ```python 671 | try: 672 | person = Person.get(id) 673 | 674 | except NotFoundError: 675 | return "Bad request", 400 676 | ``` 677 | 678 | Assuming we find the person, let's update their age and save the data back to Redis: 679 | 680 | ```python 681 | person.age = new_age 682 | person.save() 683 | ``` 684 | 685 | Let's change Kareem Khan's age from 27 to 28: 686 | 687 | ```bash 688 | curl --location --request POST 'http://127.0.0.1:5000/person/01FX8RMR7T60ANQTS4P9NKPKX8/age/28' 689 | ``` 690 | 691 | The server responds with `ok`. 692 | 693 | ### Delete a Person 694 | 695 | If we know a person's ID, we can delete them from Redis without first having to load their data into a Person object. In the function `delete_person` in `app.py`, we call the `delete` class method on the Person class to do this: 696 | 697 | ```python 698 | Person.delete(id) 699 | ``` 700 | 701 | Let's delete Dan Harris, the person with ID `01FX8RMR8545RWW4DYCE5MSZA1`: 702 | 703 | ```bash 704 | curl --location --request POST 'http://127.0.0.1:5000/person/01FX8RMR8545RWW4DYCE5MSZA1/delete' 705 | ``` 706 | 707 | The server responds with an `ok` response regardless of whether the ID provided existed in Redis. 708 | 709 | ### Setting an Expiry Time for a Person 710 | 711 | This is an example of how to run arbitrary Redis commands against instances of a model saved in Redis. Let's see how we can set the time to live (TTL) on a person, so that Redis will expire the JSON document after a configurable number of seconds have passed. 712 | 713 | The function `expire_by_id` in `app.py` handles this as follows. It takes two parameters: `id` - the ID of a person to expire, and `seconds` - the number of seconds in the future to expire the person after. This requires us to run the Redis `EXPIRE` command against the person's key. To do this, we need to access the Redis connection from the `Person` model like so: 714 | 715 | ```python 716 | person_to_expire = Person.get(id) 717 | Person.db().expire(person_to_expire.key(), seconds) 718 | ``` 719 | 720 | Let's set the person with ID `01FX8RMR82D091TC37B45RCWY3` to expire in 600 seconds: 721 | 722 | ```bash 723 | curl --location --request POST 'http://localhost:5000/person/01FX8RMR82D091TC37B45RCWY3/expire/600' 724 | ``` 725 | 726 | Using `redis-cli`, you can check that the person now has a TTL set with the Redis `expire` command: 727 | 728 | ```bash 729 | 127.0.0.1:6379> ttl :person.Person:01FX8RMR82D091TC37B45RCWY3 730 | (integer) 584 731 | ``` 732 | 733 | This shows that Redis will expire the key 584 seconds from now. 734 | 735 | You can use the `.db()` function on your model class to get at the underlying redis-py connection whenever you want to run lower level Redis commands. For more details, see the [redis-py documentation](https://redis-py.readthedocs.io/en/stable/). 736 | 737 | ## Shutting Down Redis (Docker) 738 | 739 | If you're using Docker, and want to shut down the Redis container when you are finished with the application, use `docker-compose down`: 740 | 741 | ```bash 742 | $ docker-compose down 743 | Stopping redis_om_python_flask_starter ... done 744 | Removing redis_om_python_flask_starter ... done 745 | Removing network redis-om-python-flask-skeleton-app_default 746 | ``` 747 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import json 2 | from xml.dom import NotFoundErr 3 | from flask import Flask, request 4 | from pydantic import ValidationError 5 | from person import Person 6 | from redis_om import Migrator 7 | from redis_om.model import NotFoundError 8 | 9 | app = Flask(__name__) 10 | 11 | # Utility function to format list of People objects as 12 | # a results dictionary, for easy conversion to JSON in 13 | # API responses. 14 | def build_results(people): 15 | response = [] 16 | for person in people: 17 | response.append(person.dict()) 18 | 19 | return { "results": response } 20 | 21 | # Create a new person. 22 | @app.route("/person/new", methods=["POST"]) 23 | def create_person(): 24 | try: 25 | print(request.json) 26 | new_person = Person(**request.json) 27 | new_person.save() 28 | return new_person.pk 29 | 30 | except ValidationError as e: 31 | print(e) 32 | return "Bad request.", 400 33 | 34 | # Update a person's age. 35 | @app.route("/person//age/", methods=["POST"]) 36 | def update_age(id, new_age): 37 | try: 38 | person = Person.get(id) 39 | 40 | except NotFoundError: 41 | return "Bad request", 400 42 | 43 | person.age = new_age 44 | person.save() 45 | return "ok" 46 | 47 | # Delete a person by ID. 48 | @app.route("/person//delete", methods=["POST"]) 49 | def delete_person(id): 50 | # Delete returns 1 if the person existed and was 51 | # deleted, or 0 if they didn't exist. For our 52 | # purposes, both outcomes can be considered a success. 53 | Person.delete(id) 54 | return "ok" 55 | 56 | # Find a person by ID. 57 | @app.route("/person/byid/", methods=["GET"]) 58 | def find_by_id(id): 59 | try: 60 | person = Person.get(id) 61 | return person.dict() 62 | except NotFoundError: 63 | return {} 64 | 65 | # Find people with a given first and last name. 66 | @app.route("/people/byname//", methods=["GET"]) 67 | def find_by_name(first_name, last_name): 68 | people = Person.find( 69 | (Person.first_name == first_name) & 70 | (Person.last_name == last_name) 71 | ).all() 72 | 73 | return build_results(people) 74 | 75 | # Find people within a given age range, and return them sorted by age. 76 | @app.route("/people/byage//", methods=["GET"]) 77 | def find_in_age_range(min_age, max_age): 78 | people = Person.find( 79 | (Person.age >= min_age) & 80 | (Person.age <= max_age) 81 | ).sort_by("age").all() 82 | 83 | return build_results(people) 84 | 85 | # Find people with a given skill in a given city. 86 | @app.route("/people/byskill//", methods=["GET"]) 87 | def find_matching_skill(desired_skill, city): 88 | people = Person.find( 89 | (Person.skills << desired_skill) & 90 | (Person.address.city == city) 91 | ).all() 92 | 93 | return build_results(people) 94 | 95 | # Find people whose personal statements contain a full text search match 96 | # for the supplied search term. 97 | @app.route("/people/bystatement/", methods=["GET"]) 98 | def find_matching_statements(search_term): 99 | people = Person.find(Person.personal_statement % search_term).all() 100 | 101 | return build_results(people) 102 | 103 | # Expire a person's record after a given number of seconds. 104 | @app.route("/person//expire/", methods=["POST"]) 105 | def expire_by_id(id, seconds): 106 | # Get the full Redis key for the supplied ID. 107 | try: 108 | person_to_expire = Person.get(id) 109 | Person.db().expire(person_to_expire.key(), seconds) 110 | except NotFoundError: 111 | pass 112 | 113 | # Return OK whatever happens. 114 | return "ok" 115 | 116 | @app.route("/", methods=["GET"]) 117 | def home_page(): 118 | return """ 119 | 120 | 121 | 122 | Redis OM Python / Flask Basic CRUD Demo 123 | 124 | 125 |

Redis OM Python / Flask Basic CRUD Demo

126 |

Read the documentation on GitHub.

127 | 128 | 129 | """ 130 | 131 | # Create a RediSearch index for instances of the Person model. 132 | Migrator().run() 133 | -------------------------------------------------------------------------------- /data/people.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "first_name": "Robert", 4 | "last_name": "McDonald", 5 | "age": 35, 6 | "address": { 7 | "street_number": 9, 8 | "street_name": "Main Street", 9 | "city": "Sheffield", 10 | "state": "South Yorkshire", 11 | "postal_code": "S12 2MX", 12 | "country": "United Kingdom" 13 | }, 14 | "skills": [ 15 | "guitar", 16 | "piano", 17 | "trombone" 18 | ], 19 | "personal_statement": "My name is Robert, I love meeting new people and enjoy music, coding and walking my dog." 20 | }, 21 | { 22 | "first_name": "Kareem", 23 | "last_name": "Khan", 24 | "age": 27, 25 | "address": { 26 | "street_number": 1, 27 | "unit": "A", 28 | "street_name": "The Beltway", 29 | "city": "Sheffield", 30 | "state": "South Yorkshire", 31 | "postal_code": "S1 5RE", 32 | "country": "United Kingdom" 33 | }, 34 | "skills": [ 35 | "drums", 36 | "guitar", 37 | "synths" 38 | ], 39 | "personal_statement": "I'm Kareem, a multi-instrumentalist and singer looking to join a new rock band." 40 | }, 41 | { 42 | "first_name": "Fernando", 43 | "last_name": "Ortega", 44 | "age": 43, 45 | "address": { 46 | "street_number": 1299, 47 | "street_name": "C Street", 48 | "city": "San Diego", 49 | "state": "California", 50 | "postal_code": "92102", 51 | "country": "United States" 52 | }, 53 | "skills": [ 54 | "clarinet", 55 | "oboe", 56 | "drums" 57 | ], 58 | "personal_statement": "I'm in a really cool band that plays a lot of cover songs. I'm the drummer!" 59 | }, 60 | { 61 | "first_name": "Noor", 62 | "last_name": "Vasan", 63 | "age": 37, 64 | "address": { 65 | "street_number": 12, 66 | "unit": "A-1", 67 | "street_name": "Broadway", 68 | "city": "Nottingham", 69 | "state": "Nottinghamshire", 70 | "postal_code": "NG1 1AA", 71 | "country": "United Kingdom" 72 | }, 73 | "skills": [ 74 | "vocals", 75 | "guitar" 76 | ], 77 | "personal_statement": "I sing and play the guitar, I enjoy touring and meeting new people on the road." 78 | }, 79 | { 80 | "first_name": "Dan", 81 | "last_name": "Harris", 82 | "age": 21, 83 | "address": { 84 | "street_number": 333, 85 | "street_name": "Washington Avenue", 86 | "city": "New York", 87 | "state": "New York", 88 | "postal_code": "10009", 89 | "country": "United States" 90 | }, 91 | "skills": [ 92 | "harmonica", 93 | "vocals", 94 | "trumpet" 95 | ], 96 | "personal_statement": "Dan here - I'm a singer and trumpet player based in New York city, always excited to play with a band." 97 | } 98 | ] -------------------------------------------------------------------------------- /dataloader.py: -------------------------------------------------------------------------------- 1 | import json 2 | import requests 3 | 4 | with open('data/people.json', encoding='utf-8') as f: 5 | people = json.loads(f.read()) 6 | 7 | for person in people: 8 | r = requests.post('http://127.0.0.1:5000/person/new', json = person) 9 | print(f"Created person {person['first_name']} {person['last_name']} with ID {r.text}") 10 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | redis: 4 | container_name: redis_om_python_flask_starter 5 | image: "redis/redis-stack:latest" 6 | ports: 7 | - 6379:6379 8 | - 8001:8001 9 | deploy: 10 | replicas: 1 11 | restart_policy: 12 | condition: on-failure 13 | -------------------------------------------------------------------------------- /person.py: -------------------------------------------------------------------------------- 1 | from redis_om import (EmbeddedJsonModel, Field, JsonModel) 2 | from pydantic import PositiveInt 3 | from typing import Optional, List 4 | 5 | class Address(EmbeddedJsonModel): 6 | street_number: PositiveInt = Field(index=True) 7 | 8 | # Unit isn't in all addresses, so let's make it optional 9 | # and not index it. 10 | unit: Optional[str] = Field(index=False) 11 | street_name: str = Field(index=True) 12 | city: str = Field(index=True) 13 | state: str = Field(index=True) 14 | postal_code: str = Field(index=True) 15 | 16 | # Provide a default value if none supplied... 17 | country: str = Field(index=True, default="United Kingdom") 18 | 19 | class Person(JsonModel): 20 | # Indexed for exact text matching 21 | first_name: str = Field(index=True) 22 | last_name: str = Field(index=True) 23 | 24 | # Indexed for numeric matching 25 | age: PositiveInt = Field(index=True) 26 | 27 | # Use an embedded sub-model 28 | address: Address 29 | 30 | skills: List[str] = Field(index=True) 31 | 32 | # Indexed for full text search 33 | personal_statement: str = Field(index=True, full_text_search=True) 34 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aioredis==2.0.1 2 | async-timeout==4.0.2 3 | certifi==2021.10.8 4 | charset-normalizer==2.0.12 5 | cleo==1.0.0a4 6 | click==8.0.4 7 | crashtest==0.3.1 8 | Deprecated==1.2.13 9 | Flask==2.0.3 10 | hiredis==2.0.0 11 | idna==3.3 12 | itsdangerous==2.1.0 13 | Jinja2==3.0.3 14 | MarkupSafe==2.1.0 15 | packaging==21.3 16 | pptree==3.1 17 | pydantic==1.9.0 18 | pylev==1.4.0 19 | pyparsing==3.0.7 20 | python-dotenv==0.19.2 21 | python-ulid==1.0.3 22 | redis==4.1.4 23 | redis-om==0.0.20 24 | requests==2.27.1 25 | six==1.16.0 26 | types-redis==4.1.17 27 | types-six==1.16.11 28 | typing-extensions==4.1.1 29 | urllib3==1.26.8 30 | Werkzeug==2.0.3 31 | wrapt==1.13.3 32 | -------------------------------------------------------------------------------- /screenshots/insight_explore_person.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-om-python-flask-skeleton-app/6d5a18e0f7ac53681e5e01c079a5abb68b694bf1/screenshots/insight_explore_person.png -------------------------------------------------------------------------------- /screenshots/server_running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redis-developer/redis-om-python-flask-skeleton-app/6d5a18e0f7ac53681e5e01c079a5abb68b694bf1/screenshots/server_running.png --------------------------------------------------------------------------------