Building a GraphQL API with EdgeDB and Strawberry

EdgeDB allows you to query your database with GraphQL via the built-in GraphQL extension. It enables you to expose GraphQL-driven CRUD APIs for all object types, their properties, links, and aliases. This opens up the scope for creating backendless applications where the users will directly communicate with the database. You can learn more about that in the GraphQL section of the docs.

However, as of now, EdgeDB is not ready to be used as a standalone backend. You shouldn’t expose your EdgeDB instance directly to the application’s frontend; this is insecure and will give all users full read/write access to your database. So, in this tutorial, we’ll see how you can quickly create a simple GraphQL API without using the built-in extension and give the users restricted access to the database schema. Also, we’ll implement HTTP basic authentication and demonstrate how you can write your own GraphQL validators and resolvers. This tutorial assumes you’re already familiar with GraphQL terms like schema, query, mutation, resolver, validator, etc, and have used GraphQL with some other technology before.

We’ll build the same movie organization system that we used in the Flask tutorial and expose the objects and relationships as a GraphQL API. Using the GraphQL interface, you’ll be able to fetch, create, update, and delete movie and actor objects in the database. Strawberry is a Python library that takes a code-first approach where you’ll write your object schema as Python classes. This allows us to focus more on how you can integrate EdgeDB into your workflow than the idiosyncrasies of GraphQL itself. We’ll also use the EdgeDB client to communicate with the database, FastAPI to build the authentication layer, and Uvicorn as the webserver.

Prerequisites

Before we start, make sure you have 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 strawberry-gql directory.

  1. $
  1. git clone git@github.com:edgedb/edgedb-examples.git
  1. $
  1. cd edgedb-examples/strawberry-gql

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

  1. $
  1. python3.10 -m venv .venv
  1. $
  1. source .venv/bin/activate
  1. $
  1. pip install edgedb fastapi strawberry-graphql uvicorn[standard]

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: strawberry_crud]:
  4. > strawberry_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 strawberry_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 a GraphQL API suite 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 GraphQL API

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

  1. app
  2. ├── __init__.py
  3. ├── main.py
  4. └── schemas.py

The schemas.py module contains the code that defines the GraphQL schema and builds the queries and mutations for Actor and Movie objects. The main.py module then registers the GraphQL schema, adds the authentication layer, and exposes the API to the webserver.

Write the GraphQL schema

Along with the database schema, to expose EdgeDB’s object relational model as a GraphQL API, you’ll also have to define a GraphQL schema that mirrors the object structure in the database. Strawberry allows us to express this schema via type annotated Python classes. We define the Strawberry schema in the schema.py file as follows:

  1. # strawberry-gql/app/schema.py
  2. from __future__ import annotations
  3. import json # will be used later for serialization
  4. import edgedb
  5. import strawberry
  6. client = edgedb.create_async_client()
  7. @strawberry.type
  8. class Actor:
  9. name: str | None
  10. age: int | None = None
  11. height: int | None = None
  12. @strawberry.type
  13. class Movie:
  14. name: str | None
  15. year: int | None = None
  16. actors: list[Actor] | None = None

Here, the GraphQL schema mimics our database schema. Similar to the Actor and Movie types in the EdgeDB schema, here, both the Actor and Movie models have three attributes. Likewise, the actors attribute in the Movie model represents the link between movies and actors.

Query actors

In this section, we’ll write the resolver to create the queries that’ll allow us to fetch the actor objects from the database. You’ll need to write the query resolvers as methods in a class decorated with the @strawberry.type decorator. Each method will also need to be decorated with the @strawberry.field decorator to mark them as resolvers. Resolvers can be either sync or async. In this particular case, we’ll write asynchronous resolvers that’ll act in a non-blocking manner. The query to fetch the actors is built in the schema.py file as follows:

  1. # strawberry-gql/app/schema.py
  2. ...
  3. @strawberry.type
  4. class Query:
  5. @strawberry.field
  6. async def get_actors(
  7. self, filter_name: str | None = None
  8. ) -> list[Actor]:
  9. if filter_name:
  10. actors_json = await client.query_json(
  11. """
  12. select Actor {name, age, height}
  13. filter .name=<str>$filter_name
  14. """,
  15. filter_name=filter_name,
  16. )
  17. else:
  18. actors_json = await client.query_json(
  19. """
  20. select Actor {name, age, height}
  21. """
  22. )
  23. actors = json.loads(actors_json)
  24. return [
  25. Actor(name, age, height)
  26. for (name, age, height) in (
  27. d.values() for d in actors
  28. )
  29. ]
  30. # Register the Query.
  31. schema = strawberry.Schema(query=Query)

Here, the get_actors resolver method accepts an optional filter_name parameter and returns a list of Actor type objects. The optional filter_name parameter allows us to build the capability of filtering the actor objects by name. Inside the method, we use the EdgeDB client to asynchronously query the data. The client.query_json method returns JSON serialized data which we use to create the Actor instances. Finally, we return the list of actor instances and the rest of the work is done by Strawberry. Then in the last line of the above snippet, we register the Query class to build the Schema instance.

Afterward, in the main.py module, we use FastAPI to expose the /graphql endpoint. Also, we add a basic HTTP authentication layer to demonstrate how you can easily protect your GraphQL endpoint by leveraging FastAPI’s dependency injection system. Here’s how the content of the main.py looks:

  1. # strawberry-gql/app/main.py
  2. from __future__ import annotations
  3. import secrets
  4. from typing import Literal
  5. from fastapi import (
  6. Depends, FastAPI, HTTPException, Request,
  7. Response, status
  8. )
  9. from fastapi.security import HTTPBasic, HTTPBasicCredentials
  10. from strawberry.fastapi import GraphQLRouter
  11. from app.schema import schema
  12. app = FastAPI()
  13. router = GraphQLRouter(schema)
  14. security = HTTPBasic()
  15. def auth(
  16. credentials: HTTPBasicCredentials = Depends(security)
  17. ) -> Literal[True]:
  18. """Simple HTTP Basic Auth."""
  19. correct_username = secrets.compare_digest(
  20. credentials.username, "ubuntu"
  21. )
  22. correct_password = secrets.compare_digest(
  23. credentials.password, "debian"
  24. )
  25. if not (correct_username and correct_password):
  26. raise HTTPException(
  27. status_code=status.HTTP_401_UNAUTHORIZED,
  28. detail="Incorrect email or password",
  29. headers={"WWW-Authenticate": "Basic"},
  30. )
  31. return True
  32. @router.api_route("/", methods=["GET", "POST"])
  33. async def graphql(request: Request) -> Response:
  34. return await router.handle_graphql(request=request)
  35. app.include_router(
  36. router, prefix="/graphql", dependencies=[Depends(auth)]
  37. )

First, we initialize the FastAPI app instance which will communicate with the Uvicorn webserver. Then we attach the initialized schema instance to the GraphQLRouter. The HTTPBasic class provides the machinery required to add the authentication layer. The auth function houses the implementation details of how we’re comparing the incoming and expected username and passwords as well as how the webserver is going to handle unauthorized requests. The graphql handler function is the one that handles the incoming HTTP requests. Finally, the router instance and the security handler are registered to the app instance via the app.include_router method.

We can now start querying the /graphql endpoint. We’ll use the built-in GraphiQL interface to perform the queries. Before that, let’s start the Uvicorn webserver first. Run:

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

This exposes the webserver in port 5000. Now, in your browser, go to http://localhost:5000/graphql. Here, you’ll find that the HTTP basic auth requires us to provide the username and password.

Strawberry - 图1

Currently, the allowed username and password is ubuntu and debian respectively. Provide the credentials and you’ll be taken to a page that looks like this:

Strawberry - 图2

You can write your GraphQL queries here. Let’s write a query that’ll fetch all the actors in the database and show all three of their attributes. The following query does that:

  1. query ActorQuery {
  2. getActors {
  3. age
  4. height
  5. name
  6. }
  7. }

The following response will appear on the right panel of the GraphiQL explorer:

Strawberry - 图3

Since as of now, the database doesn’t have any data, the payload is returning an empty list. Let’s write a mutation and create some actors.

Mutate actors

Mutations are also written in the schema.py file. To write a mutation, you’ll have to create a separate class where you’ll write the mutation resolvers. The resolver methods will need to be decorated with the @strawberry.mutation decorator. You can write the mutation that’ll create an actor object in the database as follows:

  1. # strawberry-gql/app/schema.py
  2. ...
  3. @strawberry.type
  4. class Mutation:
  5. @strawberry.mutation
  6. async def create_actor(
  7. self, name: str,
  8. age: int | None = None,
  9. height: int | None = None
  10. ) -> ResponseActor:
  11. actor_json = await client.query_single_json(
  12. """
  13. with new_actor := (
  14. insert Actor {
  15. name := <str>$name,
  16. age := <optional int16>$age,
  17. height := <optional int16>$height
  18. }
  19. )
  20. select new_actor {name, age, height}
  21. """,
  22. name=name,
  23. age=age,
  24. height=height,
  25. )
  26. actor = json.loads(actor_json)
  27. return Actor(
  28. actor.get("name"),
  29. actor.get("age"),
  30. actor.get("height")
  31. )
  32. # Mutation class needs to be registered here.
  33. schema = strawberry.Schema(query=Query, mutation=Mutation)

Creating a mutation also includes data validation. By type annotating the Mutation class, we’re implicitly asking Strawberry to perform data validation on the incoming request payload. Strawberry will raise an HTTP 400 if the validation fails. Let’s create an actor. Submit the following graphql query in the GraphiQL interface:

  1. mutation ActorMutation {
  2. __typename
  3. createActor(
  4. name: "Robert Downey Jr.",
  5. age: 57,
  6. height: 173
  7. ) {
  8. age
  9. height
  10. name
  11. }
  12. }

In the above mutation, name is a required field and the remaining two are optional fields. This mutation will create an actor named Robert Downey Jr. and show all three attributes— name, age, and height of the created actor in the response payload. Here’s the response:

Strawberry - 图4

Now that we’ve created an actor object, we can run the previously created query to fetch the actors. Running the ActorQuery will give you the following response:

Strawberry - 图5

You can also filter actors by their names. To do so, you’d leverage the filterName parameter of the getActors resolver:

  1. query ActorQuery {
  2. __typename
  3. getActors(filterName: "Robert Downey Jr.") {
  4. age
  5. height
  6. name
  7. }
  8. }

This will only display the filtered results. Similarly, as shown above, you can write the mutations to update and delete actors. Their implementations can be found in the schema.py file. Checkout update_actors and delete_resolvers to learn more about their implementation details. You can update one or more attributes of an actor with the following mutation:

  1. mutation ActorMutation {
  2. __typename
  3. updateActors(filterName: "Robert Downey Jr.", age: 60) {
  4. name
  5. age
  6. height
  7. }
  8. }

Running this mutation will update the age of Robert Downey Jr.. First, we filter the objects that we want to mutate via the filterName parameter and then we update the relevant attributes; in this case, we updated the age of the object. Finally, we show all the fields in the return payload. Use the GraphiQL explorer to interactively play with the full API suite.

Query movies

In the schema.py file, the query to fetch movies is constructed as follows:

  1. # strawberry-gql/app/schema.py
  2. ...
  3. @strawberry.type
  4. class Query:
  5. ...
  6. @strawberry.field
  7. async def get_movies(
  8. self, filter_name: str | None = None,
  9. ) -> list[Movie]:
  10. if filter_name:
  11. movies_json = await client.query_json(
  12. """
  13. select Movie {name, year, actors: {name, age, height}}
  14. filter .name=<str>$filter_name
  15. """,
  16. filter_name=filter_name,
  17. )
  18. else:
  19. movies_json = await client.query_json(
  20. """
  21. select Movie {name, year, actors: {name, age, height}}
  22. """
  23. )
  24. # Deserialize.
  25. movies = json.loads(movies_json)
  26. for idx, movie in enumerate(movies):
  27. actors = [
  28. Actor(name) for d in movie.get("actors", [])
  29. for name in d.values()
  30. ]
  31. movies[idx] = Movie(
  32. movie.get("name"),
  33. movie.get("year"), actors
  34. )
  35. return movies

Similar to the actor query, this also allows you to either fetch all or filter movies by the movie names. Execute the following query to see the movies in the database:

  1. query MovieQuery {
  2. __typename
  3. getMovies {
  4. actors {
  5. age
  6. height
  7. name
  8. }
  9. name
  10. year
  11. }
  12. }

This will return an empty list since the database doesn’t contain any movies. In the next section, we’ll create a mutation to create the movies and query them afterward.

Mutate movies

Before running any query to fetch the movies, let’s see how you’d construct a mutation that allows you to create movies. You can build the mutation similar to how we’ve constructed the create actor mutation. It looks like this:

  1. # strawberry-gql/app/schema.py
  2. ...
  3. @strawberry.type
  4. class Mutation:
  5. ...
  6. @strawberry.mutation
  7. async def create_movie(
  8. self,
  9. name: str,
  10. year: int | None = None,
  11. actor_names: list[str] | None = None,
  12. ) -> Movie:
  13. movie_json = await client.query_single_json(
  14. """
  15. with
  16. name := <str>$name,
  17. year := <optional int16>$year,
  18. actor_names := <optional array<str>>$actor_names,
  19. new_movie := (
  20. insert Movie {
  21. name := name,
  22. year := year,
  23. actors := (
  24. select detached Actor
  25. filter .name in array_unpack(actor_names)
  26. )
  27. }
  28. )
  29. select new_movie {
  30. name,
  31. year,
  32. actors: {name, age, height}
  33. }
  34. """,
  35. name=name,
  36. year=year,
  37. actor_names=actor_names,
  38. )
  39. movie = json.loads(movie_json)
  40. actors = [
  41. Actor(name) for d in movie.get("actors", [])
  42. for name in d.values()]
  43. return Movie(
  44. movie.get("name"),
  45. movie.get("year"),
  46. actors
  47. )

You can submit a request to this mutation to create a movie. While creating a movie, you must provide the name of the movie as it’s a required field. Also, you can optionally provide the year the movie was released and an array containing the names of the casts. If the values of the actor_names field match any existing actor in the database, the above snippet makes sure that the movie will be linked with the corresponding actors. In the GraphiQL explorer, run the following mutation to create a movie named Avengers and link the actor Robert Downey Jr. with the movie:

  1. mutation MovieMutation {
  2. __typename
  3. createMovie(
  4. name: "Avengers",
  5. actorNames: ["Robert Downey Jr."],
  6. year: 2012
  7. ) {
  8. actors {
  9. name
  10. }
  11. }
  12. }

It’ll return:

Strawberry - 图6

Now you can fetch the movies with a simple query like this one:

  1. query MovieQuery {
  2. __typename
  3. getMovies {
  4. name
  5. year
  6. actors {
  7. name
  8. }
  9. }
  10. }

You’ll then see an output similar to this:

Strawberry - 图7

Take a look at the update_movies and delete_movies resolvers to gain more insights into the implementation details of those mutations.

Conclusion

In this tutorial, you’ve seen how can use Strawberry and EdgeDB together to quickly build a fully-featured GraphQL API. Also, you have seen how FastAPI allows you add an authentication layer and serve the API in a secure manner. One thing to keep in mind here is, ideally, you’d only use GraphQL if you’re interfacing with something that already expects a GraphQL API. Otherwise, EdgeQL is always going to be more powerful and expressive than GraphQL’s query syntax.