Contents

Build your Appsync API like an API the easy way - Open Source Part 3

The problem

  Quinovas does a lot of Appsync. And I mean A LOT. The first time I worked on an Appsync project the thing that stood out most to me was how bad working with VTL was. Working with VTL felt like a penance paid to the GraphQL gods for using Appsync - the tradeoff for the ease of a fully managed service.Give how much I already work with AWS Lambda I immediately started taking advantage of Lambda for datasources, removing as much need for VTL as possible. Originally Lambda resolvers still required request and response mapping templates but AWS recently updated Appsync to use Lambda directly, with request and response templates being optional. I was ecstatic. Now I could focus on coding my logic in the language of MY CHOICE (tends to be python for more often than not for Lambda) instead of fighting with VTL.

Handling code clutter

  Once VTL was out of the way the issue became how we organize code. The approach mainly depended on the size of the codebase. For most API’s the number of different calls and the amount of code for the various calls dealing with the various types, mutations, and queries lent itself for having a Lambda function for each type. Here is an example:

schema.graphql

type Blog {
    posts: [BlogPost!]
    url: AWSURL!
}
type BlogPost {
    id: Int!
    subject: String!
    content: String!
    author: Author!
}

type Author {
    id: Int!
    name: String!
}

type query {
    GetAllBlogPosts: [BlogPost]
    GetAllBlogs: [Blog]
}

For the example schema above I could have two, maybe three, functions. The first would handle all queries and mutations for Blog types. The second would handle all queries and mutations for BlogPost types. For Author I could either:

  • Place resolver function for Author into the BlogPost Lambda (Would make sense if Author was only ever accessed as a child of BlogPost)
  • Create a Lambda just for handling Author types (Would be prudent if we were doing a lot of different queries/mutations on Author types)
  • Create a separate lambda for resolving subfields and resolve BlogPost.author from there.

Especially for large projects where you have dozens of types that each have multiple attributes that have their own resolvers this works well. For a recent project we have over 14k lines of python in the backend. Separating code based on object type made perfect sense for several reasons:

  • Readability and manageability of the code in each Lambda
  • Reducing git conflicts. The more code you have in one repo the higher the probability of two devs working on the same file at the same time.
  • Different types often take a different amount of system resources to process. An example would be a Lambda that has to do a lookup for a large amount of data to store in memory vs a Lambda that does nothing but get_item() in Dynamodb.

While this method fits the needs of some projects, it does have its drawbacks:

  • More CI/CD to manage
  • More code repositories to manage
  • More AWS resources to manage in IAAS

The other part of this is managing how to call which function inside of your Lambda. The most common solution that I have see is something like this in the handler:

def handler(event, ctx):
    parent = event["info"]["parentTypeName]
    field = event["info]["fieldName]

    if parent == "Query" and field == "GetAllBlogs":
        return get_all_blogs()
    elif parent == "Blog" and field == "author":
        return get_blog_author()
    else:
        raise NotImplemented(f"No function found for {parent}.{field}")

While this works it never really sat well with me. If I have an Appsync API then shouldn’t my backend look like an API? This crude version of a router is not up to par with what we would expect for a REST API. Why settle?

The solution

  The thoughts listed above led me to create the appsync-router project. The appsync-router tool, much like tools for rest API’s, allows you to organize your code into routes. Those routes can be organized into resolvers. Finally, all resolvers and routes can be easily built into a Lambda package where they can be tested locally and deployed using a tool such as lambda_setuptools (Another python module provided by Quinovas). The full documentation can be viewed On Github, but here is the short version.

Installing appsync-router

$: pip3.8 install appsync-router
Collecting appsync-router
  Downloading appsync_router-0.1.1-py2.py3-none-any.whl (22 kB)
Requirement already satisfied: typeguard in /Library/Frameworks/Python.frame......
Requirement already satisfied: appsync-tools in /Library/Frameworks/Python.f......
Installing collected packages: appsync-router
Successfully installed appsync-router-0.1.1

Creating our Lambda

$: appsync-router make-app

    Looks like lambda-setuptools is already installed
    You can build your Lambda package by running `python setup.py ldist_wheel`.
    See the lambda-setuptools docs for more build options.


    App created. You can test your app by running:
        appsync-router execute-resolver --event-file example.json --pprint
        appsync-router execute-lambda --event-file example.json --pprint
    from ./src/lambda_function.
    Or add a new resolver with:
        appsync-router add-resolver --name <new name>
    from ./src/lambda_function

We now have the skeleton for a lambda function, including everything we need to be able to build a Lambda package using lambda_setuptools. There is a function.py file in src/lambda_function with a handler named handler. There is also a directory src/lambda_function/resolvers. This is where all of our resolvers live. Now we probably want to edit setup.py and make any necessary changes, such as, the author name, function name/description, requirements, etc. In src/lambda_function/resolvers we will see a file named resolver_template.py with the following code in it:

$: cat src/lambda_function/resolvers/resolver_template.py
from appsync_router.resolver import router


@router.route("Query.GetFoo")
def get_foo(event):
    print("Called GetFoo!!!!!")
    return event

We can see it is decorated so that it is registered as a route for the query “GetFoo”. We can test that it works with the appsync-router script that was placed in $PATH when we installed the package, using the provided example.json as the the event file:

Executing our Lambda locally

$: cd src/lambda_function/
$: appsync-router execute-lambda --event-file example.json
Called GetFoo!!!!!
{
    "value": {
        "info": {
            "parentTypeName": "Query",
            "fieldName": "GetFoo"
        }
    },
    "route": {
        "path": "Query.GetFoo",
        "callable": "get_foo",
        "type": "named_route",
        "resolver": "resolvers.resolver_template"
    },
    "resolver": "resolvers.resolver_template"
}

The event file contains a mock event in the same format that is passed to Lambda from Appsync. The path argument says that this route will be called if event[“info”][“parentTypeName”] is “Query” and event[“info”][“fieldName”] is “GetFoo”. Now lets make a route for our example schema posted above.

Creating a new resolver

$: appsync-router add-resolver --name blog_posts
$: ls resolvers/
__init__.py  blog_posts.py  resolver_template.py

now paste the following code into src/lambda_function/resolvers/blog_posts.py:

from appsync_router.resolver import router


@router.route(path="Query.GetAllBlogPosts")
def get_all_posts(event):
    print("GetAllBlogs!!!!!")
    return event

@router.matched_route(r"^.*\.author")
def get_author(event):
    print("Getting Author!!!!!")
    return event

Passing an event as a string argument to appsync-router

$: appsync-router execute-lambda --event \
    '{"info": {"parentTypeName": "Query", "fieldName": "GetAllBlogPosts"}}'

GetAllBlogs!!!!!
{
    "value": {
        "info": {
            "parentTypeName": "Query",
            "fieldName": "GetAllBlogPosts"
        }
    },
    "route": {
        "path": "Query.GetAllBlogPosts",
        "callable": "get_all_blogs",
        "type": "named_route",
        "resolver": "resolvers.blog_posts"
    },
    "resolver": "resolvers.blog_posts"
}

Testing the child field BlogPost.author

$: appsync-router execute-lambda --event \
    '{"info": {"parentTypeName": "BlogPost", "fieldName": "author"}}'
Getting Author!!!!!
{
    "value": {},
    "route": {
        "regex": "^.*\\.author",
        "callable": "get_author",
        "priority": 0,
        "type": "matched_route",
        "resolver": "resolvers.blog_posts"
    },
    "resolver": "resolvers.blog_posts"
}

Types of routes

Notice that BlogPost.author uses the router.matched_route() decorator. So this route would match any parent type that needs to resolve its author attribute. This is really handy when you have a large number of types that all have a common attribute that needs to be resolved. Along with route() and matched_route() decorators there are also:

  • globbed_route, which allows Unix style globbing to match routes
  • default, which will be called if there are no matching routes. (otherwise an exception is raised)

In our example we used the resolve() method, but there is also resolve_all. This is used when you want to allow for multiple matches to execute sequentially. For example, if you have the following routes declared:

  • route(“Query.GetFoo”)
  • matched_route(”.*Foo”)
  • globbed_route("*Foo”)

and called resolve_all(event) then each route would fire. If you pass the chain=True argument to resolve_all() then the return value of the previous route will be passed as the event of the next matched route until the list of routes is exhausted.

Wrapping up

  One of my favorite things about this tool is how easy unit tests and local debugging become. In the past I have spent countless hours creating Appsync queries that have to be executed to test my Lambdas when often, as a backend developer, I am primarily concerned with the execution of my function, not the Appsync API itself and don’t want to mix the testing of the API with my function’s code. Using the included appsync-router script I can easily create unit tests that don’t depend on the Appsync API. I can also more easily debug my code locally and without breaking our Appsync backend, which our UI developers really seem to appreciate.

About the Author

Linux loving , Python slinging, OpenSource evangelizing Senior Solutions Architect at Quinovas