aws / aws-appsync-community

The AWS AppSync community
https://aws.amazon.com/appsync
Apache License 2.0
506 stars 32 forks source link

RFC: JavaScript Resolvers in AWS AppSync #147

Closed ahmadizm closed 1 year ago

ahmadizm commented 3 years ago

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
    }
}
MontoyaAndres commented 3 years ago

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.

tanmaybangale commented 3 years ago

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.

a-ursino commented 3 years ago

Thanks, waiting for this RFC since re:invent.

A couple of questions:

bboure commented 3 years ago

@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.

buggy commented 3 years ago

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.

bernays commented 3 years ago

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.

r0zar commented 3 years ago

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

michaelbrewer commented 3 years ago

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

michaelbrewer commented 3 years ago

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

jesuscovam commented 3 years ago

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?

r0zar commented 3 years ago

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.

duwerq commented 3 years ago

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?

Jeff-Duke commented 3 years ago
ahmadizm commented 3 years ago

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

michaelbrewer commented 3 years ago

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

amcclosky commented 3 years ago

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.

duwerq commented 3 years ago

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.

paulgalow commented 3 years ago

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.

ryan-mars commented 3 years ago

@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.

michaelbrewer commented 3 years ago

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.

ryan-mars commented 3 years ago

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

codercatdev commented 3 years ago

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 😄

michaelbrewer commented 3 years ago

@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.

KoldBrewEd commented 3 years ago

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.

dabit3 commented 3 years ago

@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.

thisisxvr commented 3 years ago

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. 🙏

KoldBrewEd commented 3 years ago

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

kldeb commented 3 years ago

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

stojanovic commented 3 years ago

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.

MarcusJones commented 3 years ago

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)

StefanSmith commented 3 years ago

Would the existing quota for "Iterations in a foreach loop in mapping templates" apply? Currently you can only iterate 1,000 times in VTL. I wonder if it would be possible to offer a more flexible model, e.g. bounded by a mapping execution timeout rather than an iteration limit? I bumped into this specifically when fetching an object from S3 (HTTP resolver + Sigv4) and transforming the JSON array it contained.

duarten commented 3 years ago

Sounds great. +1 on console.log and on simplifying pipeline resolvers.

Any plans to leverage WASM? That would be an interesting approach to add more languages.

KoldBrewEd commented 3 years ago

Thank you for the feedback so far, it's been very useful.

@MarcusJones @duarten As far as the implementation details in the backend are concerned, the technology used behind the scenes will be transparent to the developer. The end goal is to provide a JavaScript runtime that is fully managed inside a given AppSync API in the same manner as VTL is exposed today. Developers will be able to commit their Resolvers JS code using their IaC stack/tool of choice and start prototyping GraphQL queries as well as edit/test the code in the AppSync console. Everything will be managed inside the GraphQL API.

@stojanovic we plan to make the util object available potentially in Amplify Mocking.

A question I have for everyone: what else you'd like to see in JS Resolvers? Are we missing anything in the RFC that we should consider to make your developer experience better?

tyroneerasmus commented 3 years ago

@stojanovic we plan to make the util object available potentially in Amplify Mocking.

A question I have for everyone: what else you'd like to see in JS Resolvers? Are we missing anything in the RFC that we should consider to make your developer experience better?

I would prefer the mocking/testing for JS resolvers to be a standalone library/project. I don't really get the link between AppSync and Amplify and why they seem to get bundled together.

We use AppSync as a standalone service managed from CDK, with frontend also making use of standalone libraries to interact with it. Please don't make us use Amplify for something that is actually AppSync-specific.

KoldBrewEd commented 3 years ago

The library would still be available on npm, just like https://www.npmjs.com/package/amplify-velocity-template . You will still be able to use it standalone if you don't want to have an Amplify project.

As far as the link between Amplify and AppSync, we're independent sister teams and work closely together with the same objective to provide a great experience to developers.

stojanovic commented 3 years ago

@awsed That's great! I love both AppSync and Amplify, but I use them separately. For me this proposal looks good. As long as we can test the resolvers (because they often contain important business logic), and that we can add them using CloudFormation (and CDK), I am happy.

One more thing: Sometimes you need to pass some variables to the template. For example, for batch queries in DynamoDB, you need to pass table names.

I don't want to have my templates inline in the CloudFormation, because that's almost impossible to test, so I prefer loading VTL templates from separate files (each has tests).

At the moment, that's easy to do with AWS SAM, but if you want to do that using CF or CDK, you need to find the best way to pass these variables (unless I missed something). For example, we have a small JS function to replace {{TABLE_NAME}} and similar variables for CDK.

It's probably out of scope for this release, but it would be great to have an easy way to pass some variables from the CloudFormation template to JS resolvers (and maybe VTL templates, too).

bboure commented 3 years ago

In line with what @stojanovic just commented, it would be great to be able to inject or receive env variables in the resolvers the same way we can in Lambda functions. (i.e.: be able to access process.env.MY_VAR). This could easily fix loads of use cases.

duarten commented 3 years ago

Since it won't be possible to import modules, will there be a workflow to share utility functions (like justARandomString in the example) between resolvers?

bboure commented 3 years ago

A question I have for everyone: what else you'd like to see in JS Resolvers? Are we missing anything in the RFC that we should consider to make your developer experience better?

For Pipeline resolvers, it would be great to have an extra param in the util.returnEarly() method that returns the data directly to the resolver, instead of continuing to the next function in the pipeline.

eg: util.returnEarly(earlyReturnData, true);

stojanovic commented 3 years ago

Since it won't be possible to import modules, will there be a workflow to share utility functions (like justARandomString in the example) between resolvers?

You can create a bundle using webpack, esbuild, or anything similar. This is JavaScript. As long as the bundle outputs a static JS file with the valid resolver, you can do whatever you want. This is just my understanding, not an official solution, of course.

duarten commented 3 years ago

Since it won't be possible to import modules, will there be a workflow to share utility functions (like justARandomString in the example) between resolvers?

You can create a bundle using webpack, esbuild, or anything similar. This is JavaScript. As long as the bundle outputs a static JS file with the valid resolver, you can do whatever you want. This is just my understanding, not an official solution, of course.

Right, I was asking whether there would be a built-in mechanism (could even be at the cdk-level instead of api-level) instead of all of us having to configure webpack and adapt build/ci tools.

stojanovic commented 3 years ago

There's no built-in mechanism for Lambda functions, so I do not expect a built-in mechanism for this. But the esbuild command should be relatively simple, and I am sure that we'll see many creative ideas from the AWS community, as always.

But now that @duarten mentioned that, @awsed I have one more question/comment: how do we debug JavaScript resolvers? If we can see the logs in the CloudWatch, I think it would be great if we can also use source maps, as they'll require some build process for code sharing. These resolvers should still be small, even after the build step, but debugging is one of the most complicated things related to VTL resolvers.

duarten commented 3 years ago

Lambda has layers, but yeah, definitely not a big deal.

vicary commented 3 years ago

As far as the implementation details in the backend are concerned, the technology used behind the scenes will be transparent to the developer.

@awsed Since pure VTL and direct lambda is the closest alternatives available, I would be really curious on the benchmarks when compared with the new JavaScript runtime.

jonsmirl commented 3 years ago

For my use case I am trying to come up with a way for active users to monitor a small amount of IOT traffic without pointlessly sending ALL of the IOT traffic through AppSync. Simplest solution is as proposed in Issue 153, [https://github.com/aws/aws-appsync-community/issues/153] where a new subscription type is created in AppSync for aws-iot.

An alternative and less reliable scheme would be to implement subscription lifecycle events to dynamically create AWS IOT rules. For example implement Subscribe-start, Subscribe-end resolver events. Code running in these events would then dynamically create an IOT rule which forwards the selected data to lambda, and that lambda applies the message as a mutation. The Subscribe-end event would tear this chain down. The problem here is if the Subscribe-end fails these rules will accumulate and pile up pointless mutations with no listeners. So a way to catch a failed Subscribe-end would be to implement a resolver event for 'mutation with no one listening'. Code in that event could then clean up the failed Subscribe-end. This scheme gets messy if multiple people subscribe to the same IOT topic since there can be race conditions constructing and destroying the chain for forwarding the messages.

I would much prefer the solution in Issue 153 since when the session between Appsync and AWS IOT is destroyed all of the resources are automatically freed.

a-h commented 3 years ago

I think CloudFlare Workers get this right. With CloudFlare workers, you're able to bundle 3rd party libraries, but you have to keep within strict timeout limits. They have a slick CLI for managing it too - https://adrianhesketh.com/2020/01/27/using-cloudflare-workers-cors-api/

loganpowell commented 3 years ago

I just heard about this via the folks running appsyncmasterclass.com! 🙏 Thank god for this 🙏

volkanunsal commented 3 years ago

This is incredible. I just started learning about AppSync's awful template language, VTL, and my first thought was "Why didn't they just use a real programming language like Javascript?" So after some googling, I ended up here. You've expressed everything I was thinking.

I can't imagine I will be enjoying designing large scale applications with VTL. No types, no tests, no IDE support, no community –– so far it's been nothing but misery. Javascript/Typescript has everything a programmer could want.

ebisbe commented 3 years ago

Is there a way right now in VTL to do HMAC-SHA1 signature encryption ? Otherwise I hope this JS resolvers could handle them. I'm using the Flickr API and needs HMAC-SHA1 to sign requests. I couldn't find any option with current VTL.

alan-cooney commented 3 years ago

Any update on when this will appear?