DEV Community

Cover image for Lessons learned: AWS AppSync Subscriptions
Filip Pýrek for AWS Community Builders

Posted on • Updated on • Originally published at blog.purple-technology.com

Lessons learned: AWS AppSync Subscriptions

AWS AppSync

AWS AppSync, simply said API Gateway for GraphQL since it allows you to connect your GraphQL schema to different data sources like RDS, DynamoDB, Lambda, HTTP endpoint etc.

We are using AppSync in our Purple Apps to power application APIs.
Learn more in Purple Stack API docs.

The AppSync Subscriptions

First we were excited to see that AppSync supports GraphQL subscriptions. But the excitement went down slightly when we discovered that they are tightly coupled with mutations.

This feature seems to be nice when working on some super simple CRUD system, but when you start building a bigger application which has a lot of business logic hidden in asynchronous background processes like Step Functions, it starts to fall short.

We solved that problem by creating dummy mutations with pass-through lambda resolvers which can be invoked only by IAM users.
Later on we discovered "local resolvers" which can do the same job with no need for invoking lambda function.

Along the way we've stumbled upon some specific AppSync behaviours which are not obvious at first sight and information about them is "hidden" deep down in the documentation within complex sentences.

Lessons learned

1. Subscription arguments are matched against the mutation response fields, not against the mutation arguments.

Nowhere in the documentation is it said how exactly the subscription arguments matching magic works. It’s unclear whether the matching is done against the mutation arguments or the mutation response fields.

type Mutation {
  addItem(argA: ID!, argB: String!, argC: Int!): AddItemResponse!
}

type Subscription {
  # filtering by "argA" ❌
  onAddItem(argA: ID!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])

  # filtering by "fieldF" ✅
  onAddItem(fieldF: String!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])

}

type AddItemResponse {
  fieldD: String!
  fieldE: Int!
  fieldF: String!
}
Enter fullscreen mode Exit fullscreen mode

2. The subscription response must be optional

I don't understand exactly why, but the subscription response must be optional. AppSync allows you to successfully save the schema with a required subscription response, but when you try to connect to the subscription from a frontend client, it starts throwing some shallow error which doesn't explicitly tell you that subscription responses must be optional. And then you spend several hours trying to figure out where the problem is.

type Mutation {
  addItem(argA: ID!, argB: String!, argC: Int!): AddItemResponse!
}

type Subscription {
  # AddItemResponse is optinal  ✅
  onAddItem(fieldF: String!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])

  # AddItemResponse is required ❌
  onAddItem(fieldF: String!): AddItemResponse! 
  @aws_subscribe(mutations: ["addItem"])

}

type AddItemResponse {
  fieldD: String!
  fieldE: Int!
  fieldF: String!
}
Enter fullscreen mode Exit fullscreen mode

3. A subscription message contains only the fields which were requested by the mutation - other fields will be null

After some time I've found out that this is explained in three big paragraphs in the docs.

The point is that even though your lambda resolver is returning values for all the mutation fields, an AppSync subscription is only seeing the mutation fields which were selected in the mutation request. This is probably caused by the fact that resolver doesn't have to return values for fields which are not requested in the mutation request.

GraphQL Schema
type Mutation {
  addItem(argA: ID!, argB: String!, argC: Int!): AddItemResponse!
}

type Subscription {
  onAddItem(fieldA: String!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])
}

type AddItemResponse {
  fieldA: String!
  fieldB: Int!
  fieldC: String!
}
Enter fullscreen mode Exit fullscreen mode
Mutation called on backend in some asynchronous process
mutation AddItem {
  addItem(argA: "valueA", argB: "valueB", argC: 123) {
    fieldA
    fieldC
  }
}
Enter fullscreen mode Exit fullscreen mode
Subscription statement on frontend
subscription SubscribeOnAddItem {
  onAddItem(fieldA: "valueA") {
    fieldA
    fieldB
    fieldC
  }
}
Enter fullscreen mode Exit fullscreen mode
Resulting subscription message
{
  fieldA: 'valueA', 
  fieldB: null, // fieldB is null because it was not requested in the mutation
  fieldC: 123
}
Enter fullscreen mode Exit fullscreen mode

4. If the filtering field is not specified in the mutation response, the subscription is not fired

This feature could be inferred from the first point, but still it's an important thing to realize.

GraphQL Schema
type Mutation {
  addItem(argA: ID!, argB: String!, argC: Int!): AddItemResponse!
}

type Subscription {
  onAddItem(fieldA: String!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])
}

type AddItemResponse {
  fieldA: String!
  fieldB: Int!
  fieldC: String!
}
Enter fullscreen mode Exit fullscreen mode
Mutation called on backend in some asynchronous process
mutation AddItem {
  addItem(argA: "valueA", argB: "valueB", argC: 123) {
    fieldB
    fieldC
  }
}
Enter fullscreen mode Exit fullscreen mode
Subscription statement on frontend
subscription SubscribeOnAddItem {
  onAddItem(fieldA: "valueA") {
    fieldA
    fieldB
    fieldC
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case the subscription doesn't get fired because the mutation is not requesting fieldA which is used for filtering in the subscription data.

Even though your lambda resolver also returned a value for fieldA, it's not going to work because matching happens after selecting the requested fields - not before.

5. The subscription resolver is optional - but it can be used for authorization

When I was testing subscriptions, I asked myself, "what happens if I create a lambda resolver for the subscription?" So I tried and I discovered that the subscription resolver is called every time before a new subscription connection is established.

It seems like the subscription resolver is meant for authorization, because it doesn't really matter what you return as an output for the resolver. Only thing that matters is if the resolver function succeeds or fails. If it succeeds, a connection is established; if it fails, an error is sent to the frontend.

GraphQL Schema
type Mutation {
  addItem(owner: ID!, text: String!): AddItemResponse!
}

type Subscription {
  onAddItem(owner: String!): AddItemResponse 
  @aws_subscribe(mutations: ["addItem"])
}

type AddItemResponse {
  owner: String!
  text: String!
}
Enter fullscreen mode Exit fullscreen mode
Subscription resolver
'use strict'

module.exports.handler = async (event) => {
  if (event.identity.username === event.arguments.owner) {
    return null
  }
  throw new Error('Access denied')
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

AppSync is a nice and useful service but it still has some space for improvements and new features which we are definitely looking forward to.

Hopefully this article made your life easier in case you've been struggling with AppSync and you have a better overview of what you can do with it.

With ❤️ made in Brno, Czech Republic, Europe.

In case you have any questions, feel free to contact me at Twitter @FilipPyrek.

Checkout more of our articles on Purple Technology blog.

Top comments (15)

Collapse
 
matiangul profile image
Mateusz Angulski • Edited

Thank you for the article, mostly for the point number 5 as most of the people here in the comments ;)

I have one comment though:

because it doesn't really matter what you return as an output for the resolver

that's no longer true. If in the subscription response mapping template your are returning something, eg $util.toJson($context.result) then it might block you from starting the connection. Changing it to $util.toJson(null) helped me to mitigate that issue.

Image description

Collapse
 
pavankumar_pamuru profile image
Pavankumar

Great Article..
Almost these all are not in the documentation.

If this is the case in 5th point. How the feature Enhanced Filters will work not able to find working of enhanced fitlers. Can anyone know this

Collapse
 
filippyrek profile image
Filip Pýrek

Hi there,

the article was written before existence of Enhanced Filters, so I'm not sure how exactly it behaves at the moment.

Maybe somebody else will be able to help. 🙂

Collapse
 
cyuste profile image
cyuste

Thank you very much, great article!

Collapse
 
filippyrek profile image
Filip Pýrek • Edited

Thanks, happy it helped! 🙌 😊

Collapse
 
bboure profile image
Benoît Bouré

Great article!

I learned all these the hard way too :)

I did not know about 5, I had never tried it. I also wonder if you can invoke a DynamoDB resolver too (I don't see why it would not work).

Apart from authorization, I think maybe a use case for that could be to log subscription requests or do something with them, if you wanted to do that for some reason.

Collapse
 
filippyrek profile image
Filip Pýrek

Thanks for the feedback @bboure ! 🙂
Yes, the 5th point was quite important for us, because we were trying to figure out how to handle authorization for the subscriptions.

Collapse
 
dmitryame profile image
Dmitry Amelchenko

Filip, do you do anything special in your CDK stack file to enable graphQL subscription annotations? I'm kind of stuck, probably something stupid, but for me, the subscription never fires.
stackoverflow.com/questions/702773...

Collapse
 
filippyrek profile image
Filip Pýrek

Hi Dmitry, I don't use CDK for this so I can't tell you. But since the schema is being given to CloudFormation in a raw form [1] it should work out of the box. But maybe the CDK construct has some special (broken) inner mechanism for schema validation or something like that.


[1] docs.aws.amazon.com/AWSCloudFormat...

Collapse
 
alexvladut profile image
alex-vladut

Nice article @filippyrek , it's great that you put it together!
I would be curious if you have any in-depth article or reference on how you solved this problem of subscriptions being tightly coupled to the mutations by making use of local resolvers, like you're hinting in the beginning of the article:

(...) we discovered that they are tightly coupled with mutations.
We solved that problem by creating dummy mutations with pass-through lambda resolvers which can be invoked only by IAM users.
Later on we discovered "local resolvers" which can do the same job with no need for invoking lambda function.

Collapse
 
mwallenberg profile image
MWallenberg

Number 5 is no longer true - at some point during late summer 2022 it must have been patched by AWS.

We set up a resolver to throw an exception if authorization failed. It worked for a month or two, but at some point the behavior changed such that the connection is established even when the exception is thrown.

Collapse
 
mwallenberg profile image
MWallenberg

After some more testing, it seems that the behavior changed when we enabled advanced filtering. After disabling advanced filtering, the resolver can once again be used to block access as the blog post describes.

So there's a gotcha - don't enable advanced filtering if you plan on using tip number 5.

Collapse
 
zachjonesnoel profile image
Jones Zachariah Noel

Hey Filip, beautifully put across. I was stuck on the 3rd and 4th point and learnt about it the hard way. This definitely helps out developers who are trying it for the first time.

Collapse
 
filippyrek profile image
Filip Pýrek

Thank you!

Collapse
 
hoanbk profile image
nguyenhoanrv

great article