StackOverflow Clone Using AWS Appsync Pt. 1

This series of blogs will cover my journey in creating a StackOverflow clone. This React project will be coded in Typescript, and the tech and tools I will be using are:

  • AWS CDK CLI - npm i -g cdk

  • GraphQL - Data Query and manipulation language for APIs

  • AppSync - Managed API service that fulfills the role of a GraphQL Server that has access to multiple data sources and retrieves the information in a single query, solving the problems of overfetching & underfetching.

  • DynamoDB - serverless NoSQL database

  • Cognito - user authentication and authorization

And if you prefer, here is the link to my Github repo.

Initialize the Project

  •       // Create your root directory and navigate into it
          mkdir stackoverflow-clone && cd stackoverflow-clone
    
          // creates cdk and frontend directories
          mkdir cdk frontend 
    
          // navigates to the cdk directory and initializes cdk app
          cd cdk && cdk init app --language=typescript
    
          // navigates to the frontend directory and initializes the react app
          cd .. && cd frontend && npx create-react-app . --language=typescript
    

CDK

Navigate to your lib/cdk-stack.ts file and rename it to stackoverflow-frontend-stack.ts. Then paste this code. Make sure you have cdk-spa-deploy installed as a dependency! SPADeploy is an AWS CDK Construct that makes deploying a single-page application (Angular/React/Vue) to AWS S3 behind SSL/Cloudfront as easy as 5 lines of code.

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { SPADeploy } from 'cdk-spa-deploy';

export class StackOverflowFrontendStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    new SPADeploy(this, 'cfDeploy')
      .createSiteWithCloudfront({
        indexDoc: 'index.html',
        websiteFolder: '../frontend/build',
        // make sure to replace the appropriate <values> or copy the entire ARN on cloudfront
        certificateARN: "arn:aws:cloudfront::<Account-Number>:distribution/<distribution-id>"
      });

At first, the certificateARN option doesn't need to be filled out when you initially deploy. You can extract the ARN from Cloudfront and plug it in for your next deployment.

Next up, we're going to work on the cognito-stack.ts which handles user authentication. In the following code, we create a new stack with a UserPool and account creation settings for our app. This UserPool will contain all of the users that create an account on our website. We also create an app client that acts as the configuration entity for our application to interface with the user pool and handle the authentication and authorization processes securely. It provides a way to customize various settings based on your application's requirements and security preferences.

import { CfnOutput, Stack, StackProps } from "aws-cdk-lib";
import { UserPool } from "aws-cdk-lib/aws-cognito";

export class CognitoStack extends Stack {
  readonly userPool: UserPool;

  constructor(parent: Stack, id: string, props?: StackProps) {
    super(parent, id, props);

    this.userPool = new UserPool(this, "StackOverflow-UserPool", {
      autoVerify: {
        email: true,
      },
      passwordPolicy: {
        minLength: 8,
        requireLowercase: false,
        requireDigits: false,
        requireUppercase: false,
        requireSymbols: false,
      },
      selfSignUpEnabled: true,
    });

    const userPoolClient = this.userPool.addClient("StackOverflowAdminClient", {
      userPoolClientName: "stackoverflow-admin",
      authFlows: {
        userPassword: true,
        userSrp: true,
      },
      preventUserExistenceErrors: true,
    });

    new CfnOutput(this, "StackOverflow-UserPoolId", { value: this.userPool.userPoolId });
    new CfnOutput(this, "StackOverflow-UserPoolClientId", {
      value: userPoolClient.userPoolClientId,
    });
  }
}

If you notice the bottom three lines, most of our stacks will contain CrnOutputs which stand for Coudformation outputs. This prints out the identifying information we need whenever the stack is deployed. I tend to save it in my notes for that project.

The final lib stack we will create is the post-api-stack.ts. This stack will house our DynamoDB tables, lambda functions, GraphQL api, and AppSync configuration. As a result of the large amount of code, I will separate the code in 4 parts.

Imports

import {
    CfnOutput,
    Duration,
    Expiration,
    Stack,
    StackProps,
  } from "aws-cdk-lib";
  import { IUserPool } from "aws-cdk-lib/aws-cognito";
  import { AttributeType, BillingMode, Table } from "aws-cdk-lib/aws-dynamodb";
  import {
    AuthorizationType,
    FieldLogLevel,
    GraphqlApi,
    Schema,
    UserPoolDefaultAction,
  } from "@aws-cdk/aws-appsync-alpha";
  import {
    Code,
    Function as LambdaFunction,
    Runtime,
  } from "aws-cdk-lib/aws-lambda";
import { Effect, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";

If you receive any errors under these imports, make sure you have the same version numbers for all the imports. You can find my cdk/package.json here.

Stack with Lambdas and DynamoDB Tables

  interface PostApiStackProps extends StackProps {
    readonly userPool: IUserPool;
  }

  export class PostApiStack extends Stack {
    constructor(parent: Stack, id: string, props: PostApiStackProps) {
      super(parent, id, props);

      const postsTable = new Table(this, "StackOverflowPostsTable", {
        billingMode: BillingMode.PAY_PER_REQUEST,
        partitionKey: {
          name: "PK",
          type: AttributeType.STRING,
        },
        sortKey: {
          name: "SK",
          type: AttributeType.STRING,
        },
      });
      new CfnOutput(this, "StackOverflowPostsTableName", {
        value: postsTable.tableName,
      });

      const usersTable = new Table(this, "StackOverflowUsersTable", {
        billingMode: BillingMode.PAY_PER_REQUEST,
        partitionKey: {
          name: "PK",
          type: AttributeType.STRING,
        },
        sortKey: {
          name: "SK",
          type: AttributeType.STRING,
        },
      });
      new CfnOutput(this, "StackOverflowUsersTableName", {
        value: usersTable.tableName,
      });

      const postLambda = new LambdaFunction(this, "StackOverflowPostLambda", {
        runtime: Runtime.NODEJS_14_X,
        // I like to have a main.ts file in my post-lambda directory
        // that acts as a router for multiple functions.
        handler: "main.handler",
        code: Code.fromAsset("post-lambda"),
        memorySize: 512,
        environment: {
          POSTS_TABLE: postsTable.tableName,
        },
      });
      // grants read and write access to the postLambda for the postsTable
      postsTable.grantFullAccess(postLambda);

      const usersLambda = new LambdaFunction(this, "StackOverflowUserLambda", {
        runtime: Runtime.NODEJS_14_X,
        handler: "main.handler",
        code: Code.fromAsset("user-lambda"),
        memorySize: 512,
        environment: {
          USER_TABLE: usersTable.tableName,
        },
      });
      postsTable.grantFullAccess(usersLambda);

GraphQL and AppSync

      // creates an AppSync GraphQL API 
      const api = new GraphqlApi(this, "PostApi", {
        name: "post-appsync-api",
      // we will work on the actual schema later
        schema: Schema.fromAsset("./graphql/schema.graphql"),
      // Default mode of authorization is through the use of an API key
      // and they will also be authorized through cognito userpool 
        authorizationConfig: {
          defaultAuthorization: {
            authorizationType: AuthorizationType.API_KEY,
            apiKeyConfig: {
              expires: Expiration.after(Duration.days(365)),
            },
          },
          additionalAuthorizationModes: [
            {
              authorizationType: AuthorizationType.USER_POOL,
              userPoolConfig: {
                userPool: props.userPool,
                // any client app registered in this userpool has 
                // access to the GraphQL API
                appIdClientRegex: ".*",
                defaultAction: UserPoolDefaultAction.ALLOW,
              },
            },
          ],
        },
        logConfig: {
          // only errors will be logged
          fieldLogLevel: FieldLogLevel.ERROR,
        },
        // AWS X-Ray service is used to trace and analyze API requests
        // and responses. We don't need it so we disable it to prevent
        // unnecessary costs
        xrayEnabled: false,
      });

      // Prints out the AppSync GraphQL endpoint to the terminal
      new CfnOutput(this, "PostsGraphQLAPIURL", {
        value: api.graphqlUrl,
      });

      // Prints out the AppSync GraphQL API key to the terminal
      new CfnOutput(this, "PostGraphQLAPIKey", {
        value: api.apiKey || "",
      });

      // Prints out the stack region to the terminal
      new CfnOutput(this, "Stack Region", {
        value: this.region,
      });

      // ** Define the IAM role for the AppSync DataSource. This role is 
      // used to grant permissions to AppSync to interact with the other
      // AWS services
      const appSyncDataSourceRole = new Role(this, 'AppSyncDataSourceRole', {
        assumedBy: new ServicePrincipal('appsync.amazonaws.com'),
      });
      // This creates an IAM policy statement that allows the AppSync 
      // service to invoke the postLambda
      const policyStatement = new PolicyStatement({
        effect: Effect.ALLOW,
        actions: ['lambda:InvokeFunction'],
        resources: [postLambda.functionArn],
      });
      // This attaches the policy statement to the appSyncDataSourceRole 
      // so that the role is granted the necessary permissions to invoke 
      // the Lambda function.
      appSyncDataSourceRole.addToPolicy(policyStatement);

** When deploying this specific stack, I kept getting this error, "Invalid principal in policy: "SERVICE":"appsync" (Service: AmazonIdentityManagement; Status Code: 400; Error Code: MalformedPolicyDocument"

After doing some troubleshooting and research, I found out that all new cdk projects have the @aws-cdk/aws-iam:standardizedServicePrincipals feature flag set to true. This automatically sets the appsync service principal to “appsync” when it should be “appsync.amazonaws.com”. To fix this, go to your cdk.json file and set this feature flag to false.

{
  "context": {
    "@aws-cdk/aws-iam:standardizedServicePrincipals": false
  }
}

Resolvers

Finally, the last part of our post-api-stack.ts is our resolvers. A resolver in AWS AppSync is a function that maps a GraphQL operation to a data source, such as a Lambda function or a DynamoDB table (in our case, postLambda). Resolvers define how data is retrieved or mutated when a GraphQL query or mutation is executed.

Every GraphQL schema starts with the schema definition which divides the two top-level types:

  • Query - defines the read operations

  • Mutation - defines all the update operations

These will also be the typeName for our resolvers. They also have a fieldName which is the name of the function.

      const postDataSource = api.addLambdaDataSource(
        "PostDataSource",
        postLambda
      );
      postDataSource.createResolver({
        typeName: "Query",
        fieldName: "getQuestions",
      });
      postDataSource.createResolver({
        typeName: "Query",
        fieldName: "viewQuestion",
      });
      postDataSource.createResolver({
        typeName: "Query",
        fieldName: "getUser",
      });
      postDataSource.createResolver({
        typeName: "Query",
        fieldName: "getAllUsers",
      });
      postDataSource.createResolver({
        typeName: "Query",
        fieldName: "getAllTags",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "register",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "login",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "postQuestion",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "deleteQuestion",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "editQuestion",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "voteQuestion",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "postAnswer",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "deleteAnswer",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "editAnswer",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "voteAnswer",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "acceptAnswer",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "addQuesComment",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "deleteQuesComment",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "editQuesComment",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "addAnsComment",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "deleteAnsComment",
      });
      postDataSource.createResolver({
        typeName: "Mutation",
        fieldName: "editAnsComment",
      });
    }
  }

If you've ever used StackOverflow before, you know that it's a Q&A forum where you can you can react and comment to the questions and answers in order to obtain the best possible answer. So these are all the resolvers needed in order to imitate StackOverflow's UX. We will discuss the resolvers and the graphql schema in our next installment, so lets wrap this up.

Now that we have all of our stacks, navigate to your bin/cdk.ts file and put them to use!

#!/usr/bin/env node
import { App, Environment, Stack, StackProps } from 'aws-cdk-lib';
import { StackOverflowFrontendStack } from '../lib/stackoverflow-frontend-stack';
import { CognitoStack } from "../lib/cognito-stack";
import { PostApiStack } from "../lib/post-api-stack";
require("dotenv").config({ path: '.env' });

const targetRegion = "your-region-1";

const app = new App();

class StackOverflowClone extends Stack {
  constructor(parent: App, name: string, props: StackProps) {
    super(parent, name, props);

    new StackOverflowFrontendStack(this, 'StackOverflowFrontendStack', {
      env: props.env as Environment,
    });

    const cognito = new CognitoStack(this, "CognitoStack", {
      env: props.env as Environment,
    });

    new PostApiStack(this, "PostApiStack", {
      env: props.env as Environment,
      userPool: cognito.userPool,
    });
  }
}

new StackOverflowClone(app, 'StackOverflowClone', {
  env: {
    region: targetRegion,
    account: process.env.AWS_ACCOUNT,
  },
});

Conclusion

Navigate to your frontend directory in your terminal and run npm run build. This will create a build directory that the construct will deploy to an S3 bucket and uploads it to the Cloudfront distribution.

Once that is done, you need to navigate to your cdk directory and run npm run build or tsc . You need to do this because Node.js cannot run Typescript directly, therefore, your application is converted to Javascript using the Typescript compiler tsc.

Finally, make sure your terminal is in the cdk directory and run cdk deploy --all. In the next installments, we will be defining our graphql schema, resolvers, and the functions to carry them out.