Building a REST API with EdgeDB and FastAPI

EdgeDB can help you quickly build REST APIs in Python without getting into the rigmarole of using ORM libraries to handle your data effectively. Here, we’ll be using FastAPI to expose the API endpoints and EdgeDB to store the content.

We’ll build a simple event management system where you’ll be able to fetch, create, update, and delete events and event hosts via RESTful API endpoints.

Prerequisites

Before we start, make sure you’ve installed the edgedb command line tool. In this tutorial, we’ll use Python 3.10 and take advantage of the asynchronous I/O paradigm to communicate with the database more efficiently. 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 fastapi-crud directory.

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

Create a Python 3.10 virtual environment, activate it, and install the dependencies with this command. On Linux/macOS:

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

This commands will differ for Windows/Powershell users; this guide provides instructions for working with virtual environments across a range of OSes, including Windows.

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: fastapi_crud]:
  4. > fastapi_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 fastapi_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 schemaless. Let’s start designing out data model.

Schema design

The event management system will have two entities—events and users. Each event can have an optional link to a user. The goal is to create API endpoints that’ll allow us to fetch, create, update, and delete the entities while maintaining their relationships.

EdgeDB allows us to declaratively define the structure of the entities. If you’ve worked with SQLAlchemy or Django ORM, you might refer to these declarative schema definitions as models. In EdgeDB we call them “object types”.

The schema lives inside .esdl files 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 User extending Auditable {
  10. required property name -> str {
  11. constraint exclusive;
  12. constraint max_len_value(50);
  13. };
  14. }
  15. type Event extending Auditable {
  16. required property name -> str {
  17. constraint exclusive;
  18. constraint max_len_value(50);
  19. }
  20. property address -> str;
  21. property schedule -> datetime;
  22. link host -> User;
  23. }
  24. }

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. Abstract types don’t have any concrete footprints in the database, as they don’t hold any actual data. Their only job is to propagate properties, links, and constraints to the types that extend them.

The User 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 user type also defines a concrete required property called name. We impose two constraints on this property: names should be unique and shorter than 50 characters.

We also define an Event type that extends the Auditable abstract type. It also contains some additional concrete properties and links: address, schedule, and an optional link called host that corresponds to a User.

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. ├── events.py
  4. ├── main.py
  5. └── users.py

The user.py and event.py modules contain the code to build the User and Event APIs respectively. The main.py module then registers all the endpoints and exposes them to the uvicorn webserver.

User APIs

Since the User type is simpler, we’ll start with that. Let’s create a GET /users endpoint so that we can see the User objects saved in the database. You can create the API with a couple of lines of code in FastAPI:

  1. # fastapi-crud/app/users.py
  2. from __future__ import annotations
  3. import datetime
  4. from http import HTTPStatus
  5. from typing import Iterable
  6. import edgedb
  7. from fastapi import APIRouter, HTTPException, Query
  8. from pydantic import BaseModel
  9. router = APIRouter()
  10. client = edgedb.create_async_client()
  11. class RequestData(BaseModel):
  12. name: str
  13. class ResponseData(BaseModel):
  14. name: str
  15. created_at: datetime.datetime
  16. @router.get("/users")
  17. async def get_users(
  18. name: str = Query(None, max_length=50)
  19. ) -> Iterable[ResponseData]:
  20. if not name:
  21. users = await client.query(
  22. "SELECT User {name, created_at};"
  23. )
  24. else:
  25. users = await client.query(
  26. """SELECT User {name, created_at}
  27. FILTER User.name = <str>$name""",
  28. name=name,
  29. )
  30. response = (
  31. ResponseData(
  32. name=user.name,
  33. created_at=user.created_at
  34. ) for user in users
  35. )
  36. return response

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

In the get_users function, we perform asynchronous queries via the edgedb client and serialize the returned data with the ResponseData model. Then we aggregate the instances in a generator and return it. Afterward, the JSON serialization part is taken care of by FastAPI. This endpoint is exposed to the server in the main.py module. Here’s the content of the module:

  1. # fastapi-crud/app/main.py
  2. from __future__ import annotations
  3. from fastapi import FastAPI
  4. from starlette.middleware.cors import CORSMiddleware
  5. from app import events, users
  6. fast_api = FastAPI()
  7. # Set all CORS enabled origins.
  8. fast_api.add_middleware(
  9. CORSMiddleware,
  10. allow_origins=["*"],
  11. allow_credentials=True,
  12. allow_methods=["*"],
  13. allow_headers=["*"],
  14. )
  15. fast_api.include_router(events.router)
  16. fast_api.include_router(users.router)

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

  1. $
  1. uvicorn app.main:fast_api --port 5000 --reload

This will start a uvicorn server and you’ll be able to start making requests against it. 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 uvicorn server is running, on a new console, run:

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

You’ll see the following output on the console:

  1. HTTP/1.1 200 OK
  2. date: Sat, 16 Apr 2022 22:58:11 GMT
  3. server: uvicorn
  4. content-length: 2
  5. content-type: application/json
  6. []

Our request yielded an empty list because the database is currently empty. Let’s create the POST /users endpoint to start saving users in the database. The POST endpoint can be built similarly:

  1. # fastapi-crud/app/users.py
  2. ...
  3. @router.post("/users", status_code=HTTPStatus.CREATED)
  4. async def post_user(user: RequestData) -> ResponseData:
  5. try:
  6. (created_user,) = await client.query(
  7. """
  8. WITH
  9. new_user := (INSERT User {name := <str>$name})
  10. SELECT new_user {
  11. name,
  12. created_at
  13. };
  14. """,
  15. name=user.name,
  16. )
  17. except edgedb.errors.ConstraintViolationError:
  18. raise HTTPException(
  19. status_code=HTTPStatus.BAD_REQUEST,
  20. detail={
  21. "error": f"Username '{user.name}' already exists,"
  22. },
  23. )
  24. response = ResponseData(
  25. name=created_user.name,
  26. created_at=created_user.created_at,
  27. )
  28. return response

In the above snippet, we ingest data with the shape dictated by the RequestData model and return a payload with the shape defined in the ResponseData model. The try...except block gracefully handles the situation where the API consumer might try to create multiple users with the same name. A successful request will yield the status code HTTP 201 (created). To test it out, make a request as follows:

  1. $
  1. httpx -m POST http://localhost:5000/users \
  2. --json '{"name" : "Jonathan Harker"}'

The output should look similar to this:

  1. HTTP/1.1 201 Created
  2. ...
  3. {
  4. "name": "Jonathan Harker",
  5. "created_at": "2022-04-16T23:09:30.929664+00:00"
  6. }

If you try to make the same request again, it’ll throw an HTTP 400 (bad request) error:

  1. HTTP/1.1 400 Bad Request
  2. ...
  3. {
  4. "detail": {
  5. "error": "Username 'Jonathan Harker' already exists."
  6. }
  7. }

Before we move on to the next step, create 2 more users called Count Dracula and Mina Murray. Once you’ve done that, we can move on to the next step of building the PUT /users endpoint to update the user data. It can be built like this:

  1. # fastapi-crud/app/users.py
  2. ...
  3. @router.put("/users")
  4. async def put_user(
  5. user: RequestData, filter_name: str
  6. ) -> Iterable[ResponseData]:
  7. try:
  8. updated_users = await client.query(
  9. """
  10. SELECT (
  11. UPDATE User FILTER .name=<str>$filter_name
  12. SET {name:=<str>$name}
  13. ) {name, created_at};
  14. """,
  15. name=user.name,
  16. filter_name=filter_name,
  17. )
  18. except edgedb.errors.ConstraintViolationError:
  19. raise HTTPException(
  20. status_code=HTTPStatus.BAD_REQUEST,
  21. detail={
  22. "error": f"Username '{filter_name}' already exists."
  23. },
  24. )
  25. response = (
  26. ResponseData(
  27. name=user.name, created_at=user.created_at
  28. ) for user in updated_users
  29. )
  30. return response

Here, we’ll isolate the intended object that we want to update by filtering the users with the filter_name parameter. For example, if you wanted to update the properties of Jonathan Harker, the value of the filter_name query parameter would be Jonathan Harker. The following command changes the name of Jonathan Harker to Dr. Van Helsing.

  1. $
  1. httpx -m PUT http://localhost:5000/users \
  2. -p 'filter_name' 'Jonathan Harker' \
  3. --json '{"name" : "Dr. Van Helsing"}'

This will return:

  1. HTTP/1.1 200 OK
  2. ...
  3. [
  4. {
  5. "name": "Dr. Van Helsing",
  6. "created_at": "2022-04-16T23:09:30.929664+00:00"
  7. }
  8. ]

If you try to change the name of a user to match that of an existing user, the endpoint will throw an HTTP 400 (bad request) error:

  1. $
  1. httpx -m PUT http://localhost:5000/users \
  2. -p 'filter_name' 'Count Dracula' \
  3. --json '{"name" : "Dr. Van Helsing"}'

This returns:

  1. HTTP/1.1 400 Bad Request
  2. ...
  3. {
  4. "detail": {
  5. "error": "Username 'Count Dracula' already exists."
  6. }
  7. }

Another API that we’ll need to cover is the DELETE /users 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. # fastapi-crud/app/users.py
  2. ...
  3. @router.delete("/users")
  4. async def delete_user(name: str) -> Iterable[ResponseData]:
  5. try:
  6. deleted_users = await client.query(
  7. """SELECT (
  8. DELETE User FILTER .name = <str>$name
  9. ) {name, created_at};
  10. """,
  11. name=name,
  12. )
  13. except edgedb.errors.ConstraintViolationError:
  14. raise HTTPException(
  15. status_code=HTTPStatus.BAD_REQUEST,
  16. detail={
  17. "error": "User attached to an event. "
  18. "Cannot delete."
  19. },
  20. )
  21. response = (
  22. ResponseData(
  23. name=deleted_user.name,
  24. created_at=deleted_user.created_at
  25. ) for deleted_user in deleted_users
  26. )
  27. return response

This endpoint will simply delete the requested user if the user isn’t attached to any event. If the targeted object is attached to an event, the API will throw an HTTP 400 (bad request) error and refuse to delete the object. To delete Count Dracula, on your console, run:

  1. $
  1. httpx -m DELETE http://localhost:5000/users \
  2. -p 'name' 'Count Dracula'

That’ll return:

  1. HTTP/1.1 200 OK
  2. ...
  3. [
  4. {
  5. "name": "Count Dracula",
  6. "created_at": "2022-04-16T23:23:56.630101+00:00"
  7. }
  8. ]

Event APIs

The event APIs are built in a similar manner as the user APIs. Without sounding too repetitive, let’s look at how the POST /events endpoint is created and then we’ll introspect the objects created with this API via the GET /events endpoint.

Take a look at how the POST API is built:

  1. # fastapi-crud/app/events.py
  2. from __future__ import annotations
  3. import datetime
  4. from http import HTTPStatus
  5. from typing import Iterable
  6. import edgedb
  7. from fastapi import APIRouter, HTTPException, Query
  8. from pydantic import BaseModel
  9. router = APIRouter()
  10. client = edgedb.create_async_client()
  11. class RequestData(BaseModel):
  12. name: str
  13. class ResponseData(BaseModel):
  14. name: str
  15. created_at: datetime.datetime
  16. @router.post("/events", status_code=HTTPStatus.CREATED)
  17. async def post_event(event: RequestData) -> ResponseData:
  18. try:
  19. (created_event,) = await client.query(
  20. """
  21. WITH
  22. name := <str>$name,
  23. address := <str>$address,
  24. schedule := <str>$schedule,
  25. host_name := <str>$host_name
  26. SELECT (
  27. INSERT Event {
  28. name := name,
  29. address := address,
  30. schedule := <datetime>schedule,
  31. host := (SELECT User FILTER .name = host_name)
  32. }) {name, address, schedule, host: {name}};
  33. """,
  34. name=event.name,
  35. address=event.address,
  36. schedule=event.schedule,
  37. host_name=event.host_name,
  38. )
  39. except edgedb.errors.InvalidValueError:
  40. raise HTTPException(
  41. status_code=HTTPStatus.BAD_REQUEST,
  42. detail={
  43. "error": "Invalid datetime format. "
  44. "Datetime string must look like this: "
  45. "'2010-12-27T23:59:59-07:00'",
  46. },
  47. )
  48. except edgedb.errors.ConstraintViolationError:
  49. raise HTTPException(
  50. status_code=HTTPStatus.BAD_REQUEST,
  51. detail=f"Event name '{event.name}' already exists,",
  52. )
  53. return ResponseData(
  54. name=created_event.name,
  55. address=created_event.address,
  56. schedule=created_event.schedule,
  57. host=Host(
  58. name=created_event.host.name
  59. ) if created_event.host else None,
  60. )

Like the POST /users API, here, the incoming and outgoing shape of the data is defined by the RequestData and ResponseData``models respectively. The ``post_events function asynchronously inserts the data into the database and returns the fields defined in the SELECT statement. EdgeQL allows us to perform insertion and selection of data fields at the same time. The exception handling logic validates the shape of the incoming data. For example, just as before, this API will complain if you try to create multiple events with the same. Also, the field schedule accepts data as an ISO 8601 timestamp string. Failing to do so will incur an HTTP 400 (bad request) error.

Here’s how you’d create an event:

  1. $
  1. httpx -m POST http://localhost:5000/events \
  2. --json '{
  1. "name":"Resuscitation",
  2. "address":"Britain",
  3. "schedule":"1889-07-27T23:59:59-07:00",
  4. "host_name":"Mina Murray"
  5. }'

That’ll return:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "name": "Resuscitation",
  5. "address": "Britain",
  6. "schedule": "1889-07-28T06:59:59+00:00",
  7. "host": {
  8. "name": "Mina Murray"
  9. }
  10. }

You can also use the GET /events endpoint to list and filter the event objects. To locate the Resuscitation event, you’d use the filter_name parameter with the GET API as follows:

  1. $
  1. httpx -m GET http://localhost:5000/events \
  2. -p 'name' 'Resuscitation'

That’ll return:

  1. HTTP/1.1 200 OK
  2. ...
  3. {
  4. "name": "Resuscitation",
  5. "address": "Britain",
  6. "schedule": "1889-07-28T06:59:59+00:00",
  7. "host": {
  8. "name": "Mina Murray"
  9. }
  10. }

Take a look at the app/events.py file to see how the PUT /events and DELETE /events endpoints are constructed.

Browse the endpoints using the native OpenAPI doc

FastAPI automatically generates OpenAPI schema from the API endpoints and uses those to build the API docs. While the uvicorn server is running, go to your browser and head over to http://localhost:5000/docs. You should see an API navigator like this:

FastAPI - 图1

The doc allows you to play with the APIs interactively. Let’s try to make a request to the PUT /events. Click on the API that you want to try and then click on the Try it out button. You can do it in the UI as follows:

FastAPI - 图2

Clicking the execute button will make the request and return the following payload:

FastAPI - 图3