GraphQL API for Movies and Actors
In the Getting Started for GraphQL tutorial, we deployed a simple version of the GraphQL API. In this tutorial, we will be writing more complex queries and mutations, that resembles more closely to a real life application. We will also be giving a more in depth explanation to related concepts within the Zef Ecosystem.
Loading Data (Again)
Continuing from our getting started series, we have successfully transacted the Actor and Movies entities into our database. If you have still access to the database from it, you can skip this section.
However, if you don't have access to the previous database instance, you can load the data again by running the following code:
from zef import DB, ET, RT
from zef.ops import run
db = DB()
jen_law = ET.Actor["jl"](
first_name="Jennifer",
last_name="Lawrence"
)
data = [
ET.Actor(
first_name="Dwayne",
last_name="Johnson",
acted_in=ET.Movie(title="Jumanji")
),
ET.Actor(
first_name="Chris",
last_name="Evans",
acted_in=ET.Movie(title="Captain America")
),
(jen_law, RT.acted_in, ET.Movie(title="Hunger Games")),
(jen_law, RT.acted_in, ET.Movie(title="Causeway")),
]
data | db | run
Importing Zef
Lets import the modules that will be used in this tutorial.
from zef.ops import *
from zef.core import *
from zef.graphql import *
from rich.pretty import pprint
Declaring GraphQL Schema
The following code snippet represents a GraphQL schema, which is a blueprint that defines the types of data that a GraphQL server can access:
type Query {
actor(id: ID!): Actor
actors(firstName: String, lastName: String): [Actor]
movie(id: ID!): Movie
movies(title: String) : [Movie]
actedIn(id: ID!): [Movie]
}
type Mutation {
addActor(firstName: String!, lastName: String!): Actor
addMovie(title: String!, actors: [ID]!): Movie
}
type Actor {
id: ID
firstName: String
lastName: String
}
type Movie {
id: ID
title: String
actors: [Actor]
}
The schema defines two Object Types, Actor
and Movie
, with their respective fields such as id
, firstName
, lastName
, and title
. It also includes two special types, Query
and Mutation
, which serve as the entry point to the schema, with queries used for reading data and mutations for writing data.
The schema includes the following queries:
actor
- takes a compulsory argument ofID
and returns a singleActor
actors
- takes optional arguments offirstName
andlastName
and returns a list ofActor
movie
- takes a compulsory argument ofID
and returns a singleMovie
movies
- takes an optional argument oftitle
and returns a list ofMovie
actedIn
- takes a compulsory argument of an actorID
and returns a list ofMovie
that the actor has acted in
Additionally, the schema includes two mutations:
addActor
- takes compulsory arguments offirstName
andlastName
and creates anActor
instance in the database.addMovie
- takes compulsory arguments oftitle
and a list of actorid
and creates aMovie
instance with actors that acted in the movie in the database.
Generating Data Representation in Python
Next, we need a Python data structure to represent the GraphQL schema defined above. For the sake of simplicity in this tutorial, we will first define the schema as a string in a Python variable:
schema = """
type Query {
actor(id: ID!): Actor
actors(firstName: String, lastName: String): [Actor]
movie(id: ID!): Movie
movies(title: String) : [Movie]
actedIn(id: ID!): [Movie]
}
type Mutation {
addActor(firstName: String!, lastName: String!): Actor
addMovie(title: String!, actors: [ID]!): Movie
}
type Actor {
id: ID
firstName: String
lastName: String
}
type Movie {
id: ID
title: String
actors: [Actor]
}
"""
Alternatively, you could define the GraphQL schema in a separate file and read it into Python.
We can then use the helper function parse_schema
to generate a dictionary structure for the schema:
schema_dict = parse_schema(schema)
We can pretty print the resulting schema dictionary in the Python REPL using:
pprint(schema_dict, indent_guides=False)
will give the following output:
{
"GraphQLTypes": {
"Query": {
"actor": {
"type": "Actor",
"resolver": None,
"args": {"id": {"type": "ID!"}},
},
"actors": {
"type": "[Actor]",
"resolver": None,
"args": {
"firstName": {"type": "String"},
"lastName": {"type": "String"},
},
},
"movie": {
"type": "Movie",
"resolver": None,
"args": {"id": {"type": "ID!"}},
},
"movies": {
"type": "[Movie]",
"resolver": None,
"args": {"title": {"type": "String"}},
},
"actedIn": {
"type": "[Movie]",
"resolver": None,
"args": {"id": {"type": "ID!"}},
},
},
"Mutation": {
"addActor": {
"type": "Actor",
"resolver": None,
"args": {
"firstName": {"type": "String!"},
"lastName": {"type": "String!"},
},
},
"addMovie": {
"type": "Movie",
"resolver": None,
"args": {"title": {"type": "String!"}, "actors": {"type": "[ID]!"}},
},
},
"Actor": {
"id": {"type": "ID", "resolver": None},
"firstName": {"type": "String", "resolver": None},
"lastName": {"type": "String", "resolver": None},
},
"Movie": {
"id": {"type": "ID", "resolver": None},
"title": {"type": "String", "resolver": None},
"actors": {"type": "[Actor]", "resolver": None},
},
}
}
Filling in Resolvers
In order to use the schema dictionary to start the GraphQL server, we need to define the resolver functions for each of the fields in the schema that we want to use, which are currently defined as None
.
Resolvers are functions in a GraphQL server that are responsible for resolving the value of a field in a query. They are the actual implementation of the GraphQL operations defined in the schema. In ZefDB, resolvers would typically interact with the database to perform the queries or mutations specified in the GraphQL schema.
Resolvers for Object Type
To access a specific field for an Actor
or Movie
entity in ZefDB, you can use a resolver function to retrieve the field value using a "handle" to the entity. In general, you can use the following pattern to retrieve a field value from an entity:
handle | <resolver> | collect
For example, to retrieve an Actor
's first name, you can use the F.first_name
, like this:
handle | F.first_name | collect
Similarly, to get the list of actors that acted in a movie, you can use the Ins[RT.acted_in]
resolver, like this:
handle | Ins[RT.acted_in] | collect
Here, <resolver>
is a resolver function that takes a handle to an entity as input and returns the desired field value.
The snippets of the dictionary below is after we fill in all the resolvers for object type:
{
...
"Actor": {
"id": {"type": "ID", "resolver": uid},
"firstName": {"type": "String", "resolver": F.first_name},
"lastName": {"type": "String", "resolver": F.last_name},
},
"Movie": {
"id": {"type": "ID", "resolver": uid},
"title": {"type": "String", "resolver": F.title},
"actors": {"type": "[Actor]", "resolver": Ins[RT.acted_in]},
},
...
}
uid
is a ZefOp that takes in a handle to an entity and returns it's uid.
Resolvers for Queries
To write a custom resolver in Zef's GraphQL, it has to follow a certain format
@func
def name_of_resolver(query_args: dict, db: DB):
...
Let's breakdown the syntax that is happening here:
@func
is a decorator that converts a regular python function to a Zef Functionquery_args
is a dictionary of the arguments passed to the GraphQL query. The keys are the names of the arguments, and the values are their corresponding values. For example, if the query includesactors(firstName: "Tom")
, thenquery_args
would be{"firstName": "Tom"}
.db
is the ZefDB instance that was injected into the resolver using the@func(db)
decorator. The resolver can use this instance to perform queries and mutations on the database.
Lets define the custom resolvers for all five declared GraphQL query type:
@func
def get_actor(query_args, db):
id = query_args.get("id")
res = db[id] | now
return res
@func
def get_actors(query_args, db):
first_name = query_args.get("firstName", None)
last_name = query_args.get("lastName", None)
query_matching = match[
(Tuple[String, Nil], lambda x : (Z | F.first_name == x[0])),
(Tuple[Nil, String], lambda x : (Z | F.last_name == x[1])),
(
Tuple[String, String], lambda x : (
(Z | F.first_name == x[0])
& (Z | F.last_name == x[1])
)
),
(Tuple[Nil, Nil], lambda _ : Any)
]
query = (
ET.Actor
& ((first_name, last_name) | query_matching | collect)
)
res = (
db
| now
| all[query]
)
return res
@func
def get_movie(query_args, db):
id = query_args.get("id")
res = db[id] | now
return res
@func
def get_movies(query_args, db):
title = query_args.get("title", None)
query_matching = match[
(String, lambda x : (Z | F.title == x)),
(Nil, lambda _ : Any)
]
query = (
ET.Movie
& (title | query_matching | collect)
)
res = (
db
| now
| all[query]
)
return res
@func
def get_movies_acted_in(query_args, db):
id = query_args.get("id")
res = (
db[id]
| now
| Outs[RT.acted_in][ET.Movie]
)
return res
All of the code in the resolvers is just standard Zef code, which we introduced earlier in our getting started series. It's important to note that we don't use the collect
ZefOp in any of the resolvers. The collect
operator is invoked at runtime when a user makes a query or mutation.
Resolvers for Mutations
The mutation resolvers are defined in the same way as the query resolvers, with the only difference being that the mutation resolvers will commit a transaction against the database.
@func
def add_actor(query_args, db):
first_name = query_args.get("firstName")
last_name = query_args.get("lastName")
person = ET.Actor(
first_name=first_name,
last_name=last_name
) | db | run
return person
@func
def add_movie(query_args, db):
title = query_args.get("title")
actor_ids = query_args.get("actors")
new_movie = ET.Movie["m"](title=title)
refs = (
actor_ids
| map[lambda x:(db[x] | now | collect)] # Get Actor Reference
| map[lambda x: (x, RT.acted_in, new_movie)] # Create Triples
| db
| run
)
return refs[0][2]
We can see how the resolver chains in GraphQL fit nicely with ZefOp's functional coding paradigm. The resolver chains can be expressed in the following form::
(
db
| <time_semantic_operator> # Can be "now", "time_travel[-1]", Time(XXXX)
| resolver_1[arguments_1] # First-level resolver with arguments passed by the user
| resolver_2[arguments_2] # Second-level resolver with arguments passed by the user
| ...
| collect
)
The final Python dictionary of the GraphQL schema will look like the following:
schema_dict = {
"GraphQLTypes": {
"Query": {
"actor": {
"type": "Actor",
"resolver": get_actor,
"args": {"id": {"type": "ID!"}},
},
"actors": {
"type": "[Actor]",
"resolver": get_actors,
"args": {
"firstName": {"type": "String"},
"lastName": {"type": "String"},
},
},
"movie": {
"type": "Movie",
"resolver": get_movie,
"args": {"id": {"type": "ID!"}},
},
"movies": {
"type": "[Movie]",
"resolver": get_movies,
"args": {"title": {"type": "String"}},
},
"actedIn": {
"type": "[Movie]",
"resolver": get_movies_acted_in,
"args": {"id": {"type": "ID!"}},
},
},
"Mutation": {
"addActor": {
"type": "Actor",
"resolver": add_actor,
"args": {
"firstName": {"type": "String!"},
"lastName": {"type": "String!"},
},
},
"addMovie": {
"type": "Movie",
"resolver": add_movie,
"args": {"title": {"type": "String!"}, "actors": {"type": "[ID]!"}},
},
},
"Actor": {
"id": {"type": "ID", "resolver": uid},
"firstName": {"type": "String", "resolver": F.first_name},
"lastName": {"type": "String", "resolver": F.last_name},
},
"Movie": {
"id": {"type": "ID", "resolver": uid},
"title": {"type": "String", "resolver": F.title},
"actors": {"type": "[Actor]", "resolver": Ins[RT.acted_in]},
},
}
}
Exploring our API
Run the following to start a GraphQL Server with ZefFX:
my_playground = FX.GraphQL.StartPlayground(
schema_dict = schema_dict,
db = db,
port = 5002,
) | run | get['server_uuid']
Each of the keyword arguments for GraphQL.StartPlayground
should be self-explanatory. The return value is the handler to the side effect, which in this case is a dictionary with the uuid
of the GraphQL server. This can be used to interact with the server in the future.
To stop the GraphQL server, run:
FX.GraphQL.StopPlayground(
**my_playground
) | run
Hosting our API
my_server = FX.GraphQL.StartServer(
schema_dict = schema_dict,
db = db,
port = 5002,
) | run
For this command, the GraphQL playground will be disabled as we don't want to expose that page in a production setting.