aws-appsync-community icon indicating copy to clipboard operation
aws-appsync-community copied to clipboard

RFC: JavaScript Resolvers in AWS AppSync

Open ahmadizm opened this issue 3 years ago • 100 comments

GraphQL has a built-in compute or runtime component where developers can customize their own business logic directly at the API layer. These components are called Resolvers, and they provide the logical glue between the data defined in the GraphQL schema and the data in the actual data sources. Using resolvers you can map an operation or even a single field of a type defined in the schema with a specific data source, which allows to retrieve data for different fields in different data sources with a single API call. They are called “Resolvers” because they are built-in functions in GraphQL that “resolve” types or fields defined in the GraphQL schema with the data in the data sources.

AppSync currently leverages VTL or Apache Velocity Templates to provide a lean and fast compute runtime layer to resolve GraphQL queries or fields. It uses VTL internally to translate GraphQL requests from clients into a request to a data source as well as translate back the response from the data source to clients.

However, if you’re not familiar with VTL you need to learn a new language to take full advantage of these benefits, which can potentially delay the implementation of a GraphQL project for your business. While there are toolchains such as the Amplify CLI (https://docs.amplify.aws/cli/) and the GraphQL Transformer (https://docs.amplify.aws/cli/graphql-transformer/overview) that can automatically generate VTL for AppSync APIs, customers have told us that sometimes they just want to write their own resolver logic in a language they are familiar with and the preferred runtime to do so is JavaScript.

As an alternative to VTL, we're evaluating adding support for JavaScript as a runtime for AppSync resolvers. Developers will be able to leverage all native JavaScript constructs (i.e. switches, maps, global libraries such as JSON and Math, etc) in a familiar programming model based on the latest ECMAScript specification. Ideal for simple to complex business logic where no external modules are required. Just like VTL all JavaScript Resolvers code is hosted, executed and managed internally by AppSync. Customers don’t have to manage their GraphQL API resolvers business logic in other AWS services.

We propose JavaScript Resolvers are stateless and don’t have direct internet/network access to data sources, network access is handled by the AppSync service. For instance, in order to access an external API the resolver needs to be setup as an HTTP Resolver with JavaScript. AppSync executes the business logic defined in the resolver then sends the transformed request to the data source and the data source only (DB or external API). There's no access to the local file system where the Resolver is executed either. Global objects such as JSON and Math are pre-loaded and available, async/await is supported however window() and API's such as fetch and XMLHttpRequest are not.

All the utilities currently provided by VTL ($util - https://docs.aws.amazon.com/appsync/latest/devguide/resolver-util-reference.html) will be available for JavaScript Resolvers unless there’s a related utility already available natively on JavaScript (i.e. JSON.stringify, JSON.parse). Just like JSON and Math, “util” is also a global object built-in to the resolvers and automatically available. Similar to VTL, all data sources information, including connection strings, endpoints, tables and permissions, are all baked into the resolver and managed by AppSync to securely connect to the data sources.

It’s not possible to import external Node.js modules in order to maintain a lean and optimized AppSync runtime layer specifically for GraphQL workloads. If developers want to import modules or do anything more complex, the recommended approach is to use Direct Lambda Resolvers.

Please comment on this thread if you have some thoughts or suggestions on this feature or if you think we’re missing any story points which you would love to see as a part of this feature.

Sample None Resolver:

function handleRequest(context) {
    
    if (!context.arguments.title
        || !context.arguments.author) {
        const earlyReturnData = {
            "error": "Arguments title and author are required."
        };
        
        // util is a built-in global object 
        // The following line returns data immediately, bypassing handleRespnse
        // if provided.
        util.returnEarly(earlyReturnData);
        
        // Alternatively, we can call util.error with our message and data
        // util.error("Error message here", "Error type here", earlyReturnData);
    }
    
    const autoId = util.autoId(); 

    return {
        version: "2018-05-29",
        payload: {
            id: autoId,
            title:  context.arguments.title,
            author:  context.arguments.author,
            content:  justARandomString(36)
        }
    };
}

// handleResponse function is optional
// Here, we do not need to postprocess the result, so we just omit that function.
// If handleResponse function is not specified, context.result will be returned.


// Helper function
function justARandomString(length) {
    var result = "";
    for(var i=0; i < length; i++){
        var r = Math.random()*16 | 0;
        result += r.toString(16);
    }
    return result;
}

Sample DynamoDB Resolver:

function handleRequest(context) {
    var requestData = {
        "version": "2018-05-29",
        "operation": "Query",
        "query": {
            "expression": "#author = :authorId AND postedAt > :postedAfter",
            "expressionNames": {
                "#author": "authorId"
            },
            "expressionValues": {
                ":authorId": {
                    "S": context.arguments.authorId
                },
                ":postedAfter": {
                    "S": context.arguments.postedAfter
                }
            }
        },

        "index": "postedAtIdx",
        "select" : "ALL_PROJECTED_ATTRIBUTES",
        "consistentRead": true
    }

    if (context.arguments.filter) {
        requestData.filter = {
            "expression": "begins_with(#postId, :filter)",
            "expressionNames": {
                "#postId": "postId"
            },
            "expressionValues": {
                ":filter": {
                    "S": context.arguments.filter
                }
            }
        };
    }

    return requestData;
}


// while handleRequest() is mandatory, 
// handleResponse() is optional. If not present, it's a passthrough
function handleResponse(context) { 

    var result = [];

    if (context.result.items) {
        context.result.items.forEach(function(item) {
            result.push(getIdAndAuthor(item));
        })
    }

    return result;
}

// Helper function
function getIdAndAuthor(item) {
    return {
        "id": item.postId,
        "author": item.authorId
        // ignoring other fields of item
    }
}

ahmadizm avatar Jan 27 '21 00:01 ahmadizm

Very interesting! I'd like to know if is possible to add Typescript (or at least types) to these resolvers, maybe something like this:

import {Context, DynamodbRequest} from 'example-package';

function handleRequest(context: Context) {
    var requestData: DynamodbRequest = {
        "version": "2018-05-29",
        "operation": "Query",
        "query": {
            "expression": "#author = :authorId AND postedAt > :postedAfter",
            "expressionNames": {
                "#author": "authorId"
            },
            "expressionValues": {
                ":authorId": {
                    "S": context.arguments.authorId
                },
                ":postedAfter": {
                    "S": context.arguments.postedAfter
                }
            }
        },
        "index": "postedAtIdx",
        "select" : "ALL_PROJECTED_ATTRIBUTES",
        "consistentRead": true
    }
}

Where DynamodbRequest will help us to autocomplete what it needs to have. I know these resolvers won't have the functionality to import packages, but maybe can be possible to import some specific ones :). Thank you.

MontoyaAndres avatar Jan 27 '21 01:01 MontoyaAndres

This is great. Would like to know if there will be an option to auto convert the existing VTL resolver to JavaScript resolver ? For example, let's say a custom VTL resolver was developed. We would like to auto port it using some tool to JavaScript resolver without manual intervention.

tanmaybangale avatar Jan 27 '21 02:01 tanmaybangale

Thanks, waiting for this RFC since re:invent.

A couple of questions:

  • what would be the size limit of the resolver file? Even if is not possible to import JS modules I think would be possible to bake into this file external libraries compatible with this philosophy (no network requests for example) using tools such as webpack or swc.
  • having util handled using the global approach, what would be the way to manage versioning of this library?
  • even if it is not recommended (to try to keep a low execution time of the GraphQL resolver), sometimes it is necessary to query DynamoDB twice from a single resolver or query an HTTP endpoint twice (because for example, it doesn't support batching request). Would be possible to achieve that with this approach?

a-ursino avatar Jan 27 '21 08:01 a-ursino

@MontoyaAndres You can just write typescript then transpile it before it gets deployed.

@ahmadizm I think this looks great. As I was expecting, no import and no network access, which makes sense. Otherwise, what would be the point of Lambda? I also understand that resolvers must be kept light for performance.

That being said, I wonder If there will be any limit to the size of the resolver code? i.e.: What if I bypass the "no import" rule by using webpack and bundling everything inline?

Also, one of my expectations with JS resolvers was to include more custom validation in the resolver layer, allowing for FAST early-return validation. By validation, I mean rules that go beyond the GraphQL validations. Things like "this string must be > 30 chars long" or "if a is present, b must also be present". Without import, I cannot use libraries like joi or Yup.

bboure avatar Jan 27 '21 08:01 bboure

I'm wondering if there's a way re-imagine pipeline resolvers within JavaScript resolvers. Fundamentally it's still one resolver request -> data source -> response. I'm kind of thinking out loud here but what if we were able to do something like this in the middle of the resolver handler?

const result = await util.datasource.execute({ YOUR REQUEST })

AppSync would execute the request against the datasource and return the result which could then be used to determine future processing. You might need to limit the number of execute()'s in a single resolver. This would allow developers to compress an entire pipeline resolver into a single Javascript resolver and potentially implement some fancy logic about which requests needed to be implemented.

buggy avatar Jan 27 '21 10:01 buggy

I love the idea of adding support for other languages! What version of Node will be supported? I hope it is one where async/await is available at the top level in order to reduce nested callbacks. Are there any limitations in terms of CPU, network or memory?

As a side note I would love to see python supported in the future.

bernays avatar Jan 27 '21 13:01 bernays

Please add support for console.logging for debugging resolvers. :+1:

r0zar avatar Jan 27 '21 15:01 r0zar

Please add support for structured logging and same kind of X-ray functionality

michaelbrewer avatar Jan 27 '21 16:01 michaelbrewer

Support for AWS encryption. This would allow for encrypt or decrypt of data going into dynamodb, without having to build a whole pipeline resolver and a lambda function

michaelbrewer avatar Jan 27 '21 16:01 michaelbrewer

Could we have an example of an external API resolver with JavaScript like "For instance, in order to access an external API the resolver needs to be setup as an HTTP Resolver with JavaScript" please?

jesuscovam avatar Jan 27 '21 16:01 jesuscovam

This may be out of scope, but it would be really nice if resolvers for subscriptions executed when subscriptions are triggered (not at the time of initial subscription). I'm sure this is just an implementation detail, but I think it makes a lot more sense if it operated this way.

r0zar avatar Jan 27 '21 16:01 r0zar

Please add support for console.logging for debugging resolvers. 👍

Came here for this. Where would you imagine this getting logged? In the appsync cloud watch logs, right before it displays the request/response templates?

duwerq avatar Jan 27 '21 17:01 duwerq

  • Will it now be possible to have the resolvers as part of the local source code?
  • Will it be possible to export the resolver functions so they can be unit-tested?

Jeff-Duke avatar Jan 27 '21 17:01 Jeff-Duke

Very interesting feedback here, thank you. A few answers that I can provide now:

  • There are no plans for providing a tool to convert VTL resolvers to JS. On the other hand, we are evaluating simultaneous support for VTL and JS in any API, allowing every field to choose its runtime, and including JS and VTL functions within the same pipeline resolver. It is intended to provide a JS runtime with full parity with what VTL provides today.
  • There will be limits on the JS resolver code size and the number of statements that can be executed, which are still being evaluated. Please note that resolver code should only build up a data source request object quickly or map the returned results, and its efficiency is central to overall API performance.
  • For querying data source(s) more than once, pipeline resolvers should be used.
  • This const result = await util.datasource.execute({ YOUR REQUEST }) idea to replace pipeline resolvers is very interesting, but it may be considered in future.
  • We are planning to start with ECMAScript2020; in future, options may be provided to set the runtime version.
  • Adding support for console.log is also very interesting, will consider this in future as well.

ahmadizm avatar Jan 27 '21 17:01 ahmadizm

+1 on python support in the future. Fast and concise language :)

michaelbrewer avatar Jan 27 '21 18:01 michaelbrewer

I was hoping something like this was in the works. But like a few others have suggested a JS alternative would be nice. Python is an option but maybe Lua would be a good choice for this use case.

amcclosky avatar Jan 27 '21 18:01 amcclosky

Any chance on dynamic data sources for resolvers? I realize this is probably out of scope and they pipeline resolvers is supposed to be used in place of this option but have run into use cases for it.

duwerq avatar Jan 27 '21 21:01 duwerq

I'd like to suggest adding support for unique sortable identifiers such as ULID or KSUID. This would simplify and enrich interaction with DynamoDB. I could imagine this being part of the global "util" object.

paulgalow avatar Jan 27 '21 21:01 paulgalow

@paulgalow

I'd like to suggest adding support for unique sortable identifiers such as ULID or KSUID. This would simplify and enrich interaction with DynamoDB. I could imagine this being part of the global "util" object.

Couldn't this be also achieved by bundling the necessary modules with esbuild/webpack/etc. before deploying the resolver? We do this today with Lambda.

ryan-mars avatar Jan 27 '21 21:01 ryan-mars

I'd like to suggest adding support for unique sortable identifiers such as ULID or KSUID. This would simplify and enrich interaction with DynamoDB. I could imagine this being part of the global "util" object.

+1 on ULID support in utils.

michaelbrewer avatar Jan 27 '21 21:01 michaelbrewer

@michaelbrewer ULID and KSUID are already implemented in JS and other languages. Are you looking for a performance improvement or a simplified development experience?

ryan-mars avatar Jan 27 '21 21:01 ryan-mars

I think I would have more questions/review of the feature once the RFC is to an Alpha state. If you need Alpha testing please let me know 😄

codercatdev avatar Jan 27 '21 21:01 codercatdev

@michaelbrewer ULID and KSUID are already implemented in JS and other languages. Are you looking for a performance improvement or a simplified development experience?

Yes, having it in the util library would be for performance reasons, ease of use for the developer and no need for a lambda for such a simple task.

michaelbrewer avatar Jan 27 '21 21:01 michaelbrewer

The idea is that all the utilities currently provided by VTL ($util) are available for JavaScript Resolvers unless there’s a related utility already available natively on JavaScript (i.e. JSON.stringify, JSON.parse). Additional utilities are out of the scope of this RFC, but we can definitely consider it in the future.

KoldBrewEd avatar Jan 27 '21 21:01 KoldBrewEd

@a-ursino I think that for the answer to this question:

even if it is not recommended (to try to keep a low execution time of the GraphQL resolver), sometimes
it is necessary to query DynamoDB twice from a single resolver or query an HTTP endpoint twice (because
for example, it doesn't support batching request). Would be possible to achieve that with this approach?

That you could leverage pipeline resolvers.

dabit3 avatar Jan 27 '21 22:01 dabit3

We've faced a number of issues with VTL behaviour, so this is very welcome news. Are you considering AssemblyScript and WASM as a potential runtime? I have no experience with it personally; just read about it on the Shopify Engineering blog and wanted to ensure it was on your radar. Their use case and technical considerations seem similar.

Also, big +1 for debugging ability in resolvers. 🙏

thisisxvr avatar Jan 27 '21 22:01 thisisxvr

@thisisxvr we're evaluating different options. Thanks for sharing.

KoldBrewEd avatar Jan 27 '21 22:01 KoldBrewEd

Are we getting one step closer to single table design in amplify? Writing resolvers in JavaScript might make this transition easier.

kldeb avatar Jan 28 '21 02:01 kldeb

This sounds great! It would be awesome if you can publish the util object as a JavaScript library/Node.js module. One of the hard things related to the VTL templates is testing. JavaScript templates with access to util functions will solve that problem. We'll be able to use our JS testing libraries to write unit tests for these templates.

stojanovic avatar Jan 30 '21 10:01 stojanovic

Happy to see discussion on the issues we are facing with VTL! Currently I am implementing direct lambda resolvers for the same pain points listed. If I understand correctly, this RFC is proposing essentially a managed version of this concept. I imagine the proposal would have a similar web UI to the VTL editor? Under the hood, what would be the difference between managed vs. direct lambda? Is there still a lambda being created and invoked? (If so, I think it would be nice to have the option to convert the resolver) What is the intended performance implication? (I assume that if there is no lambda invoke, lower latency would be a great reason to manage it directly in AppSync)

MarcusJones avatar Jan 31 '21 08:01 MarcusJones