Skip to content

feat(event-handler): add single resolver functionality for AppSync GraphQL API #3999

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 50 commits into from
Jun 30, 2025

Conversation

arnabrahman
Copy link
Contributor

@arnabrahman arnabrahman commented Jun 1, 2025

Summary

This PR will add the ability to register single resolvers for AppSync GraphQL API.

Changes

  • Add new appsync-graphql utility inside event-handler
  • For this PR, we are only adding the single resolver functionality for AppSync GraphQL API
  • Followed this suggestion and same coding structure for appsync-events
  • TypeScript does not natively support unpacking an object as named arguments like Python’s kwargs. We must match the function signature accordingly. So this is not possible, I resolved the arguments as an object.

Example usage of the utility: Followed this

import { AppSyncGraphQLResolver } from '@aws-lambda-powertools/event-handler/appsync-graphql';
import { Logger } from '@aws-lambda-powertools/logger';
import type { Context } from 'aws-lambda';

const logger = new Logger();
const app = new AppSyncGraphQLResolver({
  logger: logger
});

const posts: Record<number, Record<string, unknown>> = {
  1: { id: '1', title: 'First book', author: 'Author1', url: 'https://amazon.com/', content: 'SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1 SAMPLE TEXT AUTHOR 1', ups: '100', downs: '10', },
  2: { id: '2', title: 'Second book', author: 'Author2', url: 'https://amazon.com', content: 'SAMPLE TEXT AUTHOR 2 SAMPLE TEXT AUTHOR 2 SAMPLE TEXT', ups: '100', downs: '10', },
  3: { id: '3', title: 'Third book', author: 'Author3', url: null, content: null, ups: null, downs: null },
  4: { id: '4', title: 'Fourth book', author: 'Author4', url: 'https://www.amazon.com/', content: 'SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4 SAMPLE TEXT AUTHOR 4', ups: '1000', downs: '0', },
  5: { id: '5', title: 'Fifth book', author: 'Author5', url: 'https://www.amazon.com/', content: 'SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT AUTHOR 5 SAMPLE TEXT', ups: '50', downs: '0', },
};


class Lambda {
  @app.resolver({ fieldName: 'getPost' })
  async getPost({ id }: { id: number; }) {
    return posts[id];

  }
  @app.resolver({ fieldName: 'addPost', typeName: 'Mutation' })
  async addPost({ id, title, author, url, content }: { id:number, title: string, author: string, url: string, content: string, }) {
    posts[id] = { id: String(id), title, author, url, content };
    return posts[id];
  }


  async handler(event: unknown, context: Context) {
    return app.resolve(event, context, {
      scope: this
    });
  }
}

const lambda = new Lambda();
export const handler = lambda.handler.bind(lambda);
type Post {
	id: ID!
	author: String!
	title: String
	content: String
	url: String
	ups: Int
	downs: Int
	relatedPosts: [Post]
}

type Mutation {
	addPost(
		id: ID!,
		author: String!,
		title: String,
		content: String,
		url: String
	): Post!
}

type Query {
	getPost(id: ID!): Post
	allPosts: [Post]
}

schema {
	query: Query
	mutation: Mutation
}

Issue number: #1166


By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

Disclaimer: We value your time and bandwidth. As such, any pull requests created on non-triaged issues might not be successful.

@boring-cyborg boring-cyborg bot added event-handler This item relates to the Event Handler Utility tests PRs that add or change tests labels Jun 1, 2025
@pull-request-size pull-request-size bot added the size/XXL PRs with 1K+ LOC, largely documentation related label Jun 1, 2025
@arnabrahman
Copy link
Contributor Author

arnabrahman commented Jun 1, 2025

I have also begun exploring the implementation of the batch resolver. I am trying the powertools-python batch resolver example.

Schema:

type Post {
	post_id: ID!
	title: String
	author: String
	relatedPosts: [Post]
}

type Query {
	getPost(post_id: ID!): Post
}

schema {
	query: Query
}
from __future__ import annotations

from typing import Any, TypedDict

from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler import AppSyncResolver
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
from aws_lambda_powertools.utilities.typing import LambdaContext

app = AppSyncResolver()
logger = Logger()

posts = {
    "1": { "post_id": "1", "title": "First book", "author": "Author1", },
    "2": { "post_id": "2", "title": "Second book", "author": "Author2", },
    "3": { "post_id": "3", "title": "Third book", "author": "Author3", },
    "4": { "post_id": "4", "title": "Fourth book", "author": "Author4", },
    "5": { "post_id": "5", "title": "Fifth book", "author": "Author5", }
}

posts_related = {
    "1": [posts["4"]],
    "2": [posts["3"], posts["5"]],
    "3": [posts["2"], posts["1"]],
    "4": [posts["2"], posts["1"]],
    "5": [],
}


def search_batch_posts(posts: list) -> dict[str, Any]:
    return {post_id: posts_related.get(post_id) for post_id in posts}

class Post(TypedDict, total=False):
    post_id: str
    title: str
    author: str

@app.resolver(type_name="Query", field_name="getPost")
def get_post(
    post_id: str = ""
) -> Post:
    return posts.get(post_id, {})


@app.batch_resolver(type_name="Query", field_name="relatedPosts")
def related_posts(event: list[AppSyncResolverEvent]) -> list[Any]:  
    # Extract all post_ids in order
    post_ids: list = [record.source.get("post_id") for record in event]  

    # Get unique post_ids while preserving order
    unique_post_ids = list(dict.fromkeys(post_ids))
    # Fetch posts in a single batch operation
    fetched_posts = search_batch_posts(unique_post_ids)

    # Return results in original order
    return [fetched_posts.get(post_id) for post_id in post_ids]


def lambda_handler(event, context: LambdaContext) -> dict:
    return app.resolve(event, context)

If I try this query

query MyQuery {
  getPost(post_id: "2") {
    relatedPosts {
      post_id
      author
      relatedPosts {
        post_id
        author
      }
    }
  }
}

There is an error: [ERROR] ResolverNotFoundError: No resolver found for 'Post.relatedPosts' Traceback (most recent call last):

If I change the typeName to Post, it works -> @app.batch_resolver(type_name="Post", field_name="relatedPosts")

Result:

{
  "data": {
    "getPost": {
      "relatedPosts": [
        {
          "post_id": "3",
          "author": "Author3",
          "relatedPosts": [
            {
              "post_id": "2",
              "author": "Author2"
            },
            {
              "post_id": "1",
              "author": "Author1"
            }
          ]
        },
        {
          "post_id": "5",
          "author": "Author5",
          "relatedPosts": []
        }
      ]
    }
  }
}

The reason is probably because parentTypeName value is Post.

Not sure what I am doing wrong here. @dreamorosi @leandrodamascena

@dreamorosi
Copy link
Contributor

Hi @arnabrahman, thank you so much for the PR.

I'm going to need a bit more time to review this, I was out of office yesterday and catching up with things today.

I expect to provide a first review tomorrow morning.

Copy link
Contributor

@dreamorosi dreamorosi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work as usual @arnabrahman - I haven't tested this on an AppSync API (will try tomorrow) but the implementation looks good. I've only left a couple minor comments.

I'll request also @leandrodamascena's review (tomorrow is ok), since he knows more about this resolver than me.

Good idea also splitting the PR for single vs batch resolvers.

@arnabrahman arnabrahman marked this pull request as draft June 5, 2025 05:29
@arnabrahman arnabrahman marked this pull request as ready for review June 5, 2025 06:06
@arnabrahman arnabrahman requested a review from dreamorosi June 5, 2025 06:06
@leandrodamascena
Copy link
Contributor

I have also begun exploring the implementation of the batch resolver. I am trying the powertools-python batch resolver example.

Schema:

type Post {
	post_id: ID!
	title: String
	author: String
	relatedPosts: [Post]
}

type Query {
	getPost(post_id: ID!): Post
}

schema {
	query: Query
}
from __future__ import annotations

from typing import Any, TypedDict

from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler import AppSyncResolver
from aws_lambda_powertools.utilities.data_classes import AppSyncResolverEvent
from aws_lambda_powertools.utilities.typing import LambdaContext

app = AppSyncResolver()
logger = Logger()

posts = {
    "1": { "post_id": "1", "title": "First book", "author": "Author1", },
    "2": { "post_id": "2", "title": "Second book", "author": "Author2", },
    "3": { "post_id": "3", "title": "Third book", "author": "Author3", },
    "4": { "post_id": "4", "title": "Fourth book", "author": "Author4", },
    "5": { "post_id": "5", "title": "Fifth book", "author": "Author5", }
}

posts_related = {
    "1": [posts["4"]],
    "2": [posts["3"], posts["5"]],
    "3": [posts["2"], posts["1"]],
    "4": [posts["2"], posts["1"]],
    "5": [],
}


def search_batch_posts(posts: list) -> dict[str, Any]:
    return {post_id: posts_related.get(post_id) for post_id in posts}

class Post(TypedDict, total=False):
    post_id: str
    title: str
    author: str

@app.resolver(type_name="Query", field_name="getPost")
def get_post(
    post_id: str = ""
) -> Post:
    return posts.get(post_id, {})


@app.batch_resolver(type_name="Query", field_name="relatedPosts")
def related_posts(event: list[AppSyncResolverEvent]) -> list[Any]:  
    # Extract all post_ids in order
    post_ids: list = [record.source.get("post_id") for record in event]  

    # Get unique post_ids while preserving order
    unique_post_ids = list(dict.fromkeys(post_ids))
    # Fetch posts in a single batch operation
    fetched_posts = search_batch_posts(unique_post_ids)

    # Return results in original order
    return [fetched_posts.get(post_id) for post_id in post_ids]


def lambda_handler(event, context: LambdaContext) -> dict:
    return app.resolve(event, context)

If I try this query

query MyQuery {
  getPost(post_id: "2") {
    relatedPosts {
      post_id
      author
      relatedPosts {
        post_id
        author
      }
    }
  }
}

There is an error: [ERROR] ResolverNotFoundError: No resolver found for 'Post.relatedPosts' Traceback (most recent call last):

If I change the typeName to Post, it works -> @app.batch_resolver(type_name="Post", field_name="relatedPosts")

Result:

{
  "data": {
    "getPost": {
      "relatedPosts": [
        {
          "post_id": "3",
          "author": "Author3",
          "relatedPosts": [
            {
              "post_id": "2",
              "author": "Author2"
            },
            {
              "post_id": "1",
              "author": "Author1"
            }
          ]
        },
        {
          "post_id": "5",
          "author": "Author5",
          "relatedPosts": []
        }
      ]
    }
  }
}

The reason is probably because parentTypeName value is Post.

Not sure what I am doing wrong here. @dreamorosi @leandrodamascena

Thanks for finding a bug in the Python documentation, @arnabrahman! You always exceed our standards with these catches ❤️ ! Yeah, the valid value is Post and not Query because when resolving a batch schema the initial Query (getPost) make a Post to relatedPosts. Tbh this should be Query as well, but in this case I think we need to pass the parent post as parameter and would difficult the example.

Thanks

Copy link
Contributor

@dreamorosi dreamorosi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your patience.

I ran some tests deploying this with an AppSync API and it works well, good work here!

I have left a couple comments:

  • one around making the methods that register handlers generic to improve experience
  • another to create a more generic resolver method that can handle other cases covered by the Python implementation

Besides this, I'd also see if we can add some helper functions like these for scalar types in this PR.

We can address the batchResolver method in a separate PR.

Thanks again!

@arnabrahman
Copy link
Contributor Author

@dreamorosi Thanks for taking the time to review this. I will look at the suggestions and get back on this.

@svozza
Copy link
Contributor

svozza commented Jun 6, 2025

This is great work. One question I have: both the AppSync events and Bedrock agents event handlers pass the event and the context objects into their resolvers and I think we should do the same here. Here's an example of what I mean:

it('tool function has access to the event variable', async () => {
    // Prepare
    const app = new BedrockAgentFunctionResolver();

    app.tool(
      async (_params, options) => {
        return options?.event;
      },
      {
        name: 'event-accessor',
        description: 'Accesses the event object',
      }
    );

    const event = createEvent('event-accessor');

    // Act
    const result = await app.resolve(event, context);

    // Assess
    expect(result.response.function).toEqual('event-accessor');
    expect(result.response.functionResponse.responseBody.TEXT.body).toEqual(
      JSON.stringify(event)
    );
  });

@arnabrahman arnabrahman marked this pull request as draft June 6, 2025 10:25
@dreamorosi dreamorosi linked an issue Jun 9, 2025 that may be closed by this pull request
2 tasks
@arnabrahman arnabrahman force-pushed the 1166-graphql-resolver branch from 81cc278 to 0817c6b Compare June 26, 2025 04:09
@arnabrahman arnabrahman marked this pull request as ready for review June 29, 2025 06:21
@arnabrahman arnabrahman requested review from dreamorosi and svozza June 29, 2025 06:21
Copy link
Contributor

@svozza svozza left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fantastic work, all my comments have been addressed. Really looking forward to using this feature myself!

Copy link
Contributor

@dreamorosi dreamorosi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the amazing work on this utility, I really appreciate the effort and the level of quality.

I'm aiming at writing the docs for this feature tomorrow and release this on Wednesday 🎉

PS: I left one last comment to clarify what I meant in one of the review items, but I don't think we should block the PR over it.

Copy link

@dreamorosi dreamorosi merged commit b91383f into aws-powertools:main Jun 30, 2025
46 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
event-handler This item relates to the Event Handler Utility size/XXL PRs with 1K+ LOC, largely documentation related tests PRs that add or change tests
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature request: GraphQL API Event Handler
4 participants