* Which Category is your question related to? *
Graphql Transform
* What AWS Services are you utilizing? *
Cognito, AWS Amplify
* Provide additional details e.g. code snippets *
So I have been using Amplify in a development setting and it is great for the tutorial projects. I especially like the Graphql Transform feature. It seems to decrease my time to market on most features. However, I am having trouble moving beyond the simple blog apps mentioned in the docs. I am trying to define my Graphql schema by following the Data Access Best Practices outlined in the Graphql Transform documentation and I can't seem to generalize to my use case very well. I have an app that defines four types:
As you might imagine there are a few many to many relationships here, but what I want to achieve is conceptually simple:
And here are the access patterns that are crucial to my app:
and here are the auth patterns that are necessary:
As you can see, these patterns are more complex than the docs and I have been banging my head against the wall trying to figure out how to set this up. In particular, I have not found a way to implement # 4 of my access patterns list. I have also not been able to implement any of the authorization rule outlined above. And the many to many relationships have been a doozy. Could I get some help here?
So I've been scouring the docs and this is all I could come up with:
type Student @model {
id: ID
name: String
classes: [StudentClass] @connection(keyName: "byStudent", fields: ["id"])
exams: [StudentExam] @connection(keyName: "byStudent", fields: ["id"])
}
type StudentExam
@model
@key(name: "byExam", fields: ["examID"])
@key(name: "byStudent", fields: ["studentID"])
@key(name:"byStudentWithScore", fields:["studentID"], queryField:"byStudentWithScore") {
id: ID!
studentID: ID
student: [Student] @connection(fields: ["studentID"])
examID: ID
exam: [Exam] @connection(fields: ["examID"])
scoreID: ID
score: Score @connection(fields: ["scoreID"])
}
type Exam @model @key(name: "byClass", fields: ["byClass"]) {
id: ID
classID: ID
class: [Class] @connection(fields: ["classID"])
students: [StudentExam] @connection(keyName: "byExam", fields: ["id"])
avgScore: Float
}
type StudentClass
@model
@key(name: "byStudent", fields: ["studentID"])
@key(name: "byClass", fields: ["classID"]) {
studentID: ID
classID: ID
student: [Student] @connection(fields: ["studentID"])
class: [Class] @connection(fields: ["classID"])
}
type Class @model {
id: ID
name: String
students: [StudentClass] @connection(keyName: "byClass", fields: ["id"])
exams: [Exam] @connection(keyName: "byClass", fields: ["id"])
}
type Score @model {
id: ID
percent: Int
}
The way I see it, the above schema can achieve everything I need except for # 4 and the authorization rules. I chose to implement the many-to-many relationships with two 1-many relationships. Does anyone have any experience with trying to retrieve a set of objects that a user (Student) does not have? In this case, I am trying to find the set of Exams for which a Student is not enrolled for, but that is associated with a Class that the Student is in. It's a fairly complicated operation to type out, but in SQL I would write something like:
select * from Exams
inner join Classes on Exams.classID=Classes.classID
where Classes.classID in (select classID from StudentClasses where classID=?)
and examID not in (select examID from StudentClasses where classID=?
Anyone got any ideas? I'm not opposed to exploring @search or writing a custom resolver, but I'm not as familiar with them
You could consider a custom query that ends on a Lambda which does two queries, specifically all exams the student has signed up for and all classes for the student and then return the ones the student does not have. There's actually not that much work involved in doing that.
I don't think @search will help you alone, as you will no need to join two tables and query on the joined tables, which is to the best of my knowledge, is not currently possible.
@houmark So what would it look like? I don't have much experience writing custom resolvers, but based on the docs I would try think @function is the way to go. Then I would try to write a lambda function that queries a dynamodb table using the aws-sdk. Does this sound reasonable?
Schema
type Student @model {
id: ID
name: String
classes: [StudentClass] @connection(keyName: "byStudent", fields: ["id"])
exams: [StudentExam] @connection(keyName: "byStudent", fields: ["id"])
**potentialExams: [Exam] @function(name: "PotentialExamsResolver")**
}
And then the lambda function would be:
Lambda
var AWS = require('aws-sdk');
var ddb = new AWS.DynamoDB();
const resolvers = {
Student: {
potentialExams: ctx => {
return <dynamo db stuff goes here>
},
},
}
exports.handler = async (event) => {
const typeHandler = resolvers[event.typeName];
if (typeHandler) {
const resolver = typeHandler[event.fieldName];
if (resolver) {
return await resolver(event);
}
}
throw new Error("Resolver not found.");
};
Alternatively, I could try to write a resolver using velocity. I'm not sure which option is more useful in my situation. Furthermore, I don't know how to achieve the 'join' operation required to achieve my goal.
You're on the right path, but instead of going straight to DynamoDB in the Lambda function, you can also do GraphQL queries, which would look something like this:
const AWS = require('aws-sdk');
const https = require('https');
const { URL } = require('url');
const query = require('./query').getStudent;
const apiRegion = process.env.REGION;
const apiYourGraphQLAPIEndpointOutput = process.env.API_YOUR_GRAPHQLAPIENDPOINTOUTPUT;
const endpoint = new URL(apiYourGraphQLAPIEndpointOutput).hostname.toString();
const variables = {
input: {
id,
},
};
const req = new AWS.HttpRequest(apiYourGraphQLAPIEndpointOutput, apiRegion);
req.method = 'POST';
req.headers.host = endpoint;
req.headers['Content-Type'] = 'application/json';
req.body = JSON.stringify({
query,
operationName: 'QueryStudent',
variables,
authMode: 'AWS_IAM',
});
const signer = new AWS.Signers.V4(req, 'appsync', true);
signer.addAuthorization(AWS.config.credentials, AWS.util.date.getDate());
console.log('req -->', JSON.stringify(req, null, 2));
const result = await new Promise((resolve, reject) => {
const httpRequest = https.request({ ...req, host: endpoint }, r => {
let body = '';
r.on('data', data => {
body += data;
});
r.on('end', () => {
resolve(JSON.parse(body.toString()));
});
r.on('error', err => {
console.log(err);
reject(err);
});
});
httpRequest.write(req.body);
httpRequest.end();
});
Kicking off two queries as a Promise.all and then comparing and returning the result as the field value would be pretty fast. You don't need to use GraphQL here, but I generally avoid going directly to DynamoDB, to make sure any mutation will also push a subscription message, which gets the UI updated near real-time. In this case, I guess you could do that though. Another benefit of the GraphQL operation is that your result objects would look closer to what Exam is in your Schema.
Make sure your function has been given access to your GraphQL API.
Also revise your resolver code, you have potentialEvents one place and potentialExams in the other one ;)
@houmark So you would not recommend using writing a custom resolver using velocity? I'm not sure what the differences are in efficiency or limitation, but for now I am just trying to implement the simplest solution.
Not at all, I don't see a need for that in your use case. In the end, the data you need to figure out the missing exams are in DynamoDB, and since that does not seem to be a huge amount of records you would return, this would be my preferred approach and you will probably also notice that it's pretty fast.
We have a Google Calendar integration done exactly in the same way with a field pointing to a
@function and that's running really smooth, even though the Lambda has to connect and authenticate with the Google API. If you don't need the exam data on some student queries, then you just won't query that field and the Lambda is never called.
The only downside I see and the only pro for Velocity is that Lambda has the cold start time, which can slow down the first query if you have little traffic to that field, but you can somewhat work around that by keeping the Lambda warm.
We tried to stay away from custom resolvers to keep our setup simple as possible (Amplify is already quite complex all around), and we have been able to besides one in particular. As it turns out we may be able to change that be a Lambda only also, but that's something we'll come back to soon :)
@houmark Thank you for your response. I'll mess around with both to see which is easier. It seems like I could set up a pipeline of resolvers with Velocity and achieve the same result. But in your experience, when is it desirable to use Velocity vs a Lambda function?
I just try to stay out of velocity as much as possible :)
@houmark So I tried your approach and it doesn't work. By calling the api in the resolver I get stuck in an infinite loop. The api call fetches Exams, which have Students, which have potentialExams, and so on. I believe I need to hit up a dynamodb table directly, either by using a pipeline of resolvers with Velocity, or by querying the db directly from the lambda function. But after looking at the Velocity documentation, I think i'll probably head that direction.
If you ensure your query is only fetching the fields you need on all levels, you should not have that problem.
Bear in mind that in the resolver you can have authentication issues to handle also. Meaning if you have advanced access control, then you may leak unwanted data. GraphQL in a Lambda would handle that.
@houmark Well authentication is already handled in my amplify setup (cognito), and I'm planning on handling authorization using the @auth directive so that won't be an issue. But I'll certainly try both ways for the custom resolver and use the easier one in the future
Not sure to be honest, but I've read that Velocity could leak data in some cases when using the @auth directive.
Good luck :)
Closing this issue - If you should run into any issues with @auth or @function please comment below and we'll re-open the ticket.
Most helpful comment
You're on the right path, but instead of going straight to DynamoDB in the Lambda function, you can also do GraphQL queries, which would look something like this:
Kicking off two queries as a Promise.all and then comparing and returning the result as the field value would be pretty fast. You don't need to use GraphQL here, but I generally avoid going directly to DynamoDB, to make sure any mutation will also push a subscription message, which gets the UI updated near real-time. In this case, I guess you could do that though. Another benefit of the GraphQL operation is that your result objects would look closer to what
Examis in your Schema.Make sure your function has been given access to your GraphQL API.
Also revise your resolver code, you have
potentialEventsone place andpotentialExamsin the other one ;)