Building a REST API with EdgeDB and Flask

The EdgeDB Python client makes it easy to integrate EdgeDB into your preferred web development stack. In this tutorial, we’ll see how you can quickly start building RESTful APIs with Flask and EdgeDB.

We’ll build a simple movie organization system where you’ll be able to fetch, create, update, and delete movies and movie actors via RESTful API endpoints.

Prerequisites

Before we start, make sure you’ve installed the edgedb command-line tool. Here, we’ll use Python 3.10 and a few of its latest features while building the APIs. A working version of this tutorial can be found on Github.

Install the dependencies

To follow along, clone the repository and head over to the flask-crud directory.

  1. $
  1. git clone git@github.com:edgedb/edgedb-examples.git
  1. $
  1. cd edgedb-examples/flask-crud

Create a Python 3.10 virtual environment, activate it, and install the dependencies with this command:

  1. $
  1. python -m venv myvenv
  1. $
  1. source myvenv/bin/activate
  1. $
  1. pip install edgedb flask 'httpx[cli]'

Initialize the database

Now, let’s initialize an EdgeDB project. From the project’s root directory:

  1. $
  1. edgedb project init
  1. Initializing project...
  2. Specify the name of EdgeDB instance to use with this project
  3. [default: flask_crud]:
  4. > flask_crud
  5. Do you want to start instance automatically on login? [y/n]
  6. > y
  7. Checking EdgeDB versions...

Once you’ve answered the prompts, a new EdgeDB instance called flask_crud will be created and started.

Connect to the database

Let’s test that we can connect to the newly started instance. To do so, run:

  1. $
  1. edgedb

You should be connected to the database instance and able to see a prompt similar to this:

  1. EdgeDB 1.x (repl 1.x)
  2. Type \help for help, \quit to quit.
  3. edgedb>

You can start writing queries here. However, the database is currently empty. Let’s start designing the data model.

Schema design

The movie organization system will have two object types—movies and actors. Each movie can have links to multiple actors. The goal is to create API endpoints that’ll allow us to fetch, create, update, and delete the objects while maintaining their relationships.

EdgeDB allows us to declaratively define the structure of the objects. The schema lives inside .esdl file in the dbschema directory. It’s common to declare the entire schema in a single file dbschema/default.esdl. This is how our datatypes look:

  1. # dbschema/default.esdl
  2. module default {
  3. abstract type Auditable {
  4. property created_at -> datetime {
  5. readonly := true;
  6. default := datetime_current();
  7. }
  8. }
  9. type Actor extending Auditable {
  10. required property name -> str {
  11. constraint max_len_value(50);
  12. }
  13. property age -> int16 {
  14. constraint min_value(0);
  15. constraint max_value(100);
  16. }
  17. property height -> int16 {
  18. constraint min_value(0);
  19. constraint max_value(300);
  20. }
  21. }
  22. type Movie extending Auditable {
  23. required property name -> str {
  24. constraint max_len_value(50);
  25. }
  26. property year -> int16{
  27. constraint min_value(1850);
  28. };
  29. multi link actors -> Actor;
  30. }
  31. }

Here, we’ve defined an abstract type called Auditable to take advantage of EdgeDB’s schema mixin system. This allows us to add a created_at property to multiple types without repeating ourselves.

The Actor type extends Auditable and inherits the created_at property as a result. This property is auto-filled via the datetime_current function. Along with the inherited type, the actor type also defines a few additional properties like called name, age, and height. The constraints on the properties make sure that actor names can’t be longer than 50 characters, age must be between 0 to 100 years, and finally, height must be between 0 to 300 centimeters.

We also define a Movie type that extends the Auditable abstract type. It also contains some additional concrete properties and links: name, year, and an optional multi-link called actors which refers to the Actor objects.

Build the API endpoints

The API endpoints are defined in the app directory. The directory structure looks as follows:

  1. app
  2. ├── __init__.py
  3. ├── actors.py
  4. ├── main.py
  5. └── movies.py

The actors.py and movies.py modules contain the code to build the Actor and Movie APIs respectively. The main.py module then registers all the endpoints and exposes them to the webserver.

Fetch actors

Since the Actor type is simpler, we’ll start with that. Let’s create a GET /actors endpoint so that we can see the Actor objects saved in the database. You can create the API in Flask like this:

  1. # flask-crud/app/actors.py
  2. from __future__ import annotations
  3. import json
  4. from http import HTTPStatus
  5. import edgedb
  6. from flask import Blueprint, request
  7. actor = Blueprint("actor", __name__)
  8. client = edgedb.create_client()
  9. @actor.route("/actors", methods=["GET"])
  10. def get_actors() -> tuple[dict, int]:
  11. filter_name = request.args.get("filter_name")
  12. if not filter_name:
  13. actors = client.query_json(
  14. """
  15. select Actor {
  16. name,
  17. age,
  18. height
  19. }
  20. """
  21. )
  22. else:
  23. actors = client.query_json(
  24. """
  25. select Actor {
  26. name,
  27. age,
  28. height
  29. }
  30. filter .name = <str>$filter_name
  31. """,
  32. filter_name=filter_name,
  33. )
  34. response_payload = {"result": json.loads(actors)}
  35. return response_payload, HTTPStatus.OK

The Blueprint instance does the actual work of exposing the API. We also create a blocking EdgeDB client instance to communicate with the database. By default, this API will return a list of actors, but you can also filter the objects by name.

In the get_actors function, we perform the database query via the edgedb client. Here, the client.query_json method conveniently returns JSON serialized objects. We deserialize the returned data in the response_payload dictionary and then return it. Afterward, the final JSON serialization part is taken care of by Flask. This endpoint is exposed to the server in the main.py module. Here’s the content of the module:

  1. # flask-crud/app/main.py
  2. from __future__ import annotations
  3. from flask import Flask
  4. from app.actors import actor
  5. from app.movies import movie
  6. app = Flask(__name__)
  7. app.register_blueprint(actor)
  8. app.register_blueprint(movie)

To test the endpoint, go to the flask-crud directory and run:

  1. $
  1. export FLASK_APP=app.main:app && flask run --reload

This will start the development server and make it accessible via port 5000. Earlier, we installed the HTTPx client library to make HTTP requests programmatically. It also comes with a neat command-line tool that we’ll use to test our API.

While the development server is running, on a new console, run:

  1. $
  1. httpx -m GET http://localhost:5000/actors

You’ll see the following output on the console:

  1. HTTP/1.1 200 OK
  2. Server: Werkzeug/2.1.1 Python/3.10.4
  3. Date: Wed, 27 Apr 2022 18:58:38 GMT
  4. Content-Type: application/json
  5. Content-Length: 2
  6. {
  7. "result": []
  8. }

Our request yielded an empty list because the database is currently empty. Let’s create the POST /actors endpoint to start saving actors in the database.

Create actor

The POST endpoint can be built similarly:

  1. # flask-crud/app/actors.py
  2. ...
  3. @actor.route("/actors", methods=["POST"])
  4. def post_actor() -> tuple[dict, int]:
  5. incoming_payload = request.json
  6. # Data validation.
  7. if not incoming_payload:
  8. return {
  9. "error": "Bad request"
  10. }, HTTPStatus.BAD_REQUEST
  11. if not (name := incoming_payload.get("name")):
  12. return {
  13. "error": "Field 'name' is required."
  14. }, HTTPStatus.BAD_REQUEST
  15. if len(name) > 50:
  16. return {
  17. "error": "Field 'name' cannot be longer than 50 "
  18. "characters."
  19. }, HTTPStatus.BAD_REQUEST
  20. if age := incoming_payload.get("age"):
  21. if 0 <= age <= 100:
  22. return {
  23. "error": "Field 'age' must be between 0 "
  24. "and 100."
  25. }, HTTPStatus.BAD_REQUEST
  26. if height := incoming_payload.get("height"):
  27. if not 0 <= height <= 300:
  28. return {
  29. "error": "Field 'height' must between 0 and "
  30. "300 cm."
  31. }, HTTPStatus.BAD_REQUEST
  32. # Create object.
  33. actor = client.query_single_json(
  34. """
  35. with
  36. name := <str>$name,
  37. age := <optional int16>$age,
  38. height := <optional int16>$height
  39. select (
  40. insert Actor {
  41. name := name,
  42. age := age,
  43. height := height
  44. }
  45. ){ name, age, height };
  46. """,
  47. name=name,
  48. age=age,
  49. height=height,
  50. )
  51. response_payload = {"result": json.loads(actor)}
  52. return response_payload, HTTPStatus.CREATED

In the above snippet, we perform data validation in the conditional blocks and then make the query to create the object in the database. For now, we’ll only allow creating a single object per request. The client.query_single_json ensures that we’re creating and returning only one object. Inside the query string, notice, how we’re using <optional type> to deal with the optional fields. If the user doesn’t provide the value of an optional field like age or height, it’ll be defaulted to null.

To test it out, make a request as follows:

  1. $
  1. httpx -m POST http://localhost:5000/actors \
  2. -j '{"name" : "Robert Downey Jr."}'

The output should look similar to this:

  1. HTTP/1.1 201 CREATED
  2. ...
  3. {
  4. "result": {
  5. "age": null,
  6. "height": null,
  7. "name": "Robert Downey Jr."
  8. }
  9. }

Before we move on to the next step, create 2 more actors called Chris Evans and Natalie Portman. Now that we have some data in the database, let’s make a GET request to see the objects:

  1. $
  1. httpx -m GET http://localhost:5000/actors

The response looks as follows:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "result": [
  5. {
  6. "age": null,
  7. "height": null,
  8. "name": "Robert Downey Jr."
  9. },
  10. {
  11. "age": null,
  12. "height": null,
  13. "name": "Chris Evans"
  14. },
  15. {
  16. "age": null,
  17. "height": null,
  18. "name": "Natalie Portman"
  19. }
  20. ]
  21. }

You can filter the output of the GET /actors by name. To do so, use the filter_name query parameter like this:

  1. $
  1. httpx -m GET http://localhost:5000/actors \
  2. -p filter_name "Robert Downey Jr."

Doing this will only display the data of a single object:

  1. HTTP/1.1 200 OK
  2. {
  3. "result": [
  4. {
  5. "age": null,
  6. "height": null,
  7. "name": "Robert Downey Jr."
  8. }
  9. ]
  10. }

Once you’ve done that, we can move on to the next step of building the PUT /actors endpoint to update the actor data.

Update actor

It can be built like this:

  1. # flask-crud/app/actors.py
  2. # ...
  3. @actor.route("/actors", methods=["PUT"])
  4. def put_actors() -> tuple[dict, int]:
  5. incoming_payload = request.json
  6. filter_name = request.args.get("filter_name")
  7. # Data validation.
  8. if not incoming_payload:
  9. return {
  10. "error": "Bad request"
  11. }, HTTPStatus.BAD_REQUEST
  12. if not filter_name:
  13. return {
  14. "error": "Query parameter 'filter_name' must "
  15. "be provided",
  16. }, HTTPStatus.BAD_REQUEST
  17. if (name:=incoming_payload.get("name")) and len(name) > 50:
  18. return {
  19. "error": "Field 'name' cannot be longer than "
  20. "50 characters."
  21. }, HTTPStatus.BAD_REQUEST
  22. if age := incoming_payload.get("age"):
  23. if age <= 0:
  24. return {
  25. "error": "Field 'age' cannot be less than "
  26. "or equal to 0."
  27. }, HTTPStatus.BAD_REQUEST
  28. if height := incoming_payload.get("height"):
  29. if not 0 <= height <= 300:
  30. return {
  31. "error": "Field 'height' must between 0 "
  32. "and 300 cm."
  33. }, HTTPStatus.BAD_REQUEST
  34. # Update object.
  35. actors = client.query_json(
  36. """
  37. with
  38. filter_name := <str>$filter_name,
  39. name := <optional str>$name,
  40. age := <optional int16>$age,
  41. height := <optional int16>$height
  42. select (
  43. update Actor
  44. filter .name = filter_name
  45. set {
  46. name := name ?? .name,
  47. age := age ?? .age,
  48. height := height ?? .height
  49. }
  50. ){ name, age, height };""",
  51. filter_name=filter_name,
  52. name=name,
  53. age=age,
  54. height=height,
  55. )
  56. response_payload = {"result": json.loads(actors)}
  57. return response_payload, HTTPStatus.OK

Here, we’ll isolate the intended object that we want to update by filtering the actors with the filter_name parameter. For example, if you wanted to update the properties of Robert Downey Jr., the value of the filter_name query parameter would be Robert Downey Jr.. The coalesce operator ?? in the query string makes sure that the API user can selectively update the properties of the target object and the other properties keep their existing values.

The following command updates the age and height of Robert Downey Jr..

  1. $
  1. httpx -m PUT http://localhost:5000/actors \
  2. -p filter_name "Robert Downey Jr." \
  3. -j '{"age": 57, "height": 173}'

This will return:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "result": [
  5. {
  6. "age": 57,
  7. "height": 173,
  8. "name": "Robert Downey Jr."
  9. }
  10. ]
  11. }

Delete actor

Another API that we’ll need to cover is the DELETE /actors endpoint. It’ll allow us to query the name of the targeted object and delete that. The code looks similar to the ones you’ve already seen:

  1. # flask-crud/app/actors.py
  2. ...
  3. @actor.route("/actors", methods=["DELETE"])
  4. def delete_actors() -> tuple[dict, int]:
  5. if not (filter_name := request.args.get("filter_name")):
  6. return {
  7. "error": "Query parameter 'filter_name' must "
  8. "be provided",
  9. }, HTTPStatus.BAD_REQUEST
  10. try:
  11. actors = client.query_json(
  12. """select (
  13. delete Actor
  14. filter .name = <str>$filter_name
  15. ) {name}
  16. """,
  17. filter_name=filter_name,
  18. )
  19. except edgedb.errors.ConstraintViolationError:
  20. return (
  21. {
  22. "error": f"Cannot delete '{filter_name}. "
  23. "Actor is associated with at least one movie."
  24. },
  25. HTTPStatus.BAD_REQUEST,
  26. )
  27. response_payload = {"result": json.loads(actors)}
  28. return response_payload, HTTPStatus.OK

This endpoint will simply delete the requested actor if the actor isn’t attached to any movie. If the targeted object is attached to a movie, then API will throw an HTTP 400 (bad request) error and refuse to delete the object. To delete Natalie Portman, on your console, run:

  1. $
  1. httpx -m DELETE http://localhost:5000/actors \
  2. -p filter_name "Natalie Portman"

That’ll return:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "result": [
  5. {
  6. "name": "Natalie Portman"
  7. }
  8. ]
  9. }

Now let’s move on to building the Movie API.

Create movie

Here’s how we’ll implement the POST /movie endpoint:

  1. # flask-crud/app/movies.py
  2. from __future__ import annotations
  3. import json
  4. from http import HTTPStatus
  5. import edgedb
  6. from flask import Blueprint, request
  7. movie = Blueprint("movie", __name__)
  8. client = edgedb.create_client()
  9. @movie.route("/movies", methods=["POST"])
  10. def post_movie() -> tuple[dict, int]:
  11. incoming_payload = request.json
  12. # Data validation.
  13. if not incoming_payload:
  14. return {
  15. "error": "Bad request"
  16. }, HTTPStatus.BAD_REQUEST
  17. if not (name := incoming_payload.get("name")):
  18. return {
  19. "error": "Field 'name' is required."
  20. }, HTTPStatus.BAD_REQUEST
  21. if len(name) > 50:
  22. return {
  23. "error": "Field 'name' cannot be longer than "
  24. "50 characters."
  25. }, HTTPStatus.BAD_REQUEST
  26. if year := incoming_payload.get("year"):
  27. if year < 1850:
  28. return {
  29. "error": "Field 'year' cannot be less "
  30. "than 1850."
  31. }, HTTPStatus.BAD_REQUEST
  32. actor_names = incoming_payload.get("actor_names")
  33. # Create object.
  34. movie = client.query_single_json(
  35. """
  36. with
  37. name := <str>$name,
  38. year := <optional int16>$year,
  39. actor_names := <optional array<str>>$actor_names
  40. select (
  41. insert Movie {
  42. name := name,
  43. year := year,
  44. actors := (
  45. select Actor
  46. filter .name in array_unpack(actor_names)
  47. )
  48. }
  49. ){ name, year, actors: {name, age, height} };
  50. """,
  51. name=name,
  52. year=year,
  53. actor_names=actor_names,
  54. )
  55. response_payload = {"result": json.loads(movie)}
  56. return response_payload, HTTPStatus.CREATED

Like the POST /actors API, conditional blocks validate the shape of the incoming data and the client.query_json method creates the object in the database. EdgeQL allows us to perform insertion and selection of data fields at the same time in a single query. One thing that’s different here is that the POST /movies API also accepts an optional field called actor_names where the user can provide an array of actor names. The backend will associate the actors with the movie object if those actors exist in the database.

Here’s how you’d create a movie:

  1. $
  1. httpx -m POST http://localhost:5000/movies \
  2. -j '{ "name": "The Avengers", "year": 2012, "actor_names": [ "Robert Downey Jr.", "Chris Evans" ] }'

That’ll return:

  1. HTTP/1.1 201 CREATED
  2. ...
  3. {
  4. "result": {
  5. "actors": [
  6. {
  7. "age": null,
  8. "height": null,
  9. "name": "Chris Evans"
  10. },
  11. {
  12. "age": 57,
  13. "height": 173,
  14. "name": "Robert Downey Jr."
  15. }
  16. ],
  17. "name": "The Avengers",
  18. "year": 2012
  19. }
  20. }

Additional movie endpoints

The implementation of the GET /movie, PATCH /movie and DELETE /movie endpoints are provided in the sample codebase in app/movies.py. But try to write them on your own using the Actor endpoints as a starting point! Once you’re down, you should be able to fetch a movie by it’s title from your database by the filter_name parameter with the GET API as follows:

  1. $
  1. httpx -m GET http://localhost:5000/movies \
  2. -p 'filter_name' 'The Avengers'

That’ll return:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "result": [
  5. {
  6. "actors": [
  7. {
  8. "age": null,
  9. "name": "Chris Evans"
  10. },
  11. {
  12. "age": 57,
  13. "name": "Robert Downey Jr."
  14. }
  15. ],
  16. "name": "The Avengers",
  17. "year": 2012
  18. }
  19. ]
  20. }

Conclusion

While builing REST APIs, the EdgeDB client allows you to leverage EdgeDB with any microframework of your choice. Whether it’s FastAPI, Flask, AIOHTTP, Starlette, or Tornado, the core workflow is quite similar to the one demonstrated above; you’ll query and serialize data with the client and then return the payload for your framework to process.