Getting started with serverless using Amplify + React - Part 3: API

This tutorial aims to give an introduction to creating serverless web applications in the cloud. There are several providers of serverless tools, we will work with AWS Amplify which allows grouping and managing the tools that the application will use from a single console and with a single CLI. We will create a React application that allows authentication through Cognito, create endpoints to Lambda functions, save data in Dynamo DB, and additionally we will configure a CI/CD pipeline.

Add the API

In the previous part of this tutorial, the authentication was added as a service in Amplify, and also the application can now verify if the user is authenticated with the user pools or through an API key to make requests. This step will show how to add the GraphQL API as a service in Amplify and configure it to work with the React application.

The application needs to have the following functionalities:

  1. Client-side:
    1. List available T-Shirt models
      1. Create new orders
        1. Order information
        2. Client information
      2. List orders
      3. See order status
  2. Owner side
    1. CRUD orders
    2. CRUD T-shirt models

According to the list above, the app needs four tables: Orders, Clients, Products, and Delivery. To start creating the APIs run:

amplify add api

This is the process we used for this tutorial:

Once it is finished, VS Code is going to open the GraphQL template that Amplify created. Here is where you define the tables that we mentioned above. This is what was created for this tutorial:

type Client @model @auth(rules: [{ allow: owner, provider: userPools }]) {
 id: ID!
 user: String!
 name: String!
 lastname: String!
 city: String!
 state: String!
 country: String!
 zip: String!
 phone: String!
 email: AWSEmail!
 deliveryAddress: [Delivery!] @hasMany
 orders: [Order] @hasMany
}

type Delivery @model @auth(rules: [{ allow: owner, provider: userPools }]) {
 id: ID!
 city: String!
 state: String!
 country: String!
 zip: String!
 phone: String!
 address: String!
 details: String
 order: Order @belongsTo
 client: Client @belongsTo
}

type Product @model @auth(rules: [
 { allow: public, operations: [read], provider: iam},
 { allow: owner, provider: userPools }
]) {
 id: ID!
 name: String!
 price: Float!
 weight: Float
 options: [ProductOptions!]!
 thumbnail: AWSURL!
 images: [AWSURL!]!
 description: String!
 avilable: Boolean!
 unlimited: Boolean!
 extraDetails: String
 order: [Order!] @manyToMany(relationName: "ProductOrders")
}

type ProductOptions {
 name: String!
 thumbnail: String!
 colorCode: String!
 stock: Int!
}

type Order @model @auth(rules: [{ allow: owner, provider: userPools }]) {
 id: ID!
 title: String!
 date: AWSDateTime!
 total: Float!
 orderDetails: [OrderDetail!]!
 client: Client @belongsTo
 delivery: Delivery @hasOne
 status: OrderStatus!
 paymentStatus:PaymentStatus!
 paymentType: PaymentType!
 products: [Product!] @manyToMany(relationName: "ProductOrders")
}

type OrderDetail {
 productID: String!
 productName: String!
 productColor: String!
 productThumbnail: String!
 quantity: Int!
 total: Float!
}

enum OrderStatus {
 received
 procesing
 delivering
 delivered
 canceled
}

enum PaymentStatus {
 procesing
 acepted
 rejected
 pending
}

enum PaymentType {
 cash
 bankWire
 creditCard
 paypal
}

To use this, deploy the created API to your AWS account with:

amplify push

As it is creating a new API, Amplify will ask if you want to generate the code for the newly created API. This part is really important since it generates the infrastructure as code (IaC), and also types for all the possible operations for the API that can be used in the front end.

Now the API is ready to be used in the application. Amplify will display the endpoint and the API KEY ready to be used:

Use the API in the React frontend

In order to use the GraphQL API pushed to Amplify, AWS provides all the necessary tools through the aws-amplify dependency, the aws-exports file, and all the types that were created when the API was generated by the CLI.

Below you will find an example of how to create the products with the GraphQL API.

These are the imports required and what they do:

import React, { useState } from 'react';
import ReactDOM from 'react-dom/client';
import awsExports from './aws-exports';
import * as AwsUI from '@awsui/components-react';
import * as UiReact from '@aws-amplify/ui-react';
import { useForm } from 'react-hook-form';

import { createProduct } from './graphql/mutations';
import { CreateProductInput } from './API';
import { GraphQLResult } from '@aws-amplify/api-graphql';
import { Amplify, API, graphqlOperation } from 'aws-amplify';

import { Authenticator, View } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

createProduct: This is the GraphQL API mutation used to accept an object which contains the new product info and create it.

CreateProductInput: This is the type that you can use for the form validation. It contains all the fields that a product should have. So it’s pretty easy to use a form validator and feed it with this type. (Generated by the AWS CLI)

Amplify: Helps to load the aws-exports file that contains locally all that has been generated from AWS.

API: This class facilitates all the operations that can be performed by the API.

graphqlOperation: Generates the parameters needed to perform a GraphQL request.

To create a new product you can create a form and a function that calls the API for the creation. Here you can see an example of you to do it:

const addProduct = (data: CreateProductInput) => {
   setLoading(true);

   const createProductRequest: Promise<GraphQLResult<any>> = API.graphql({
     ...graphqlOperation(createProduct, { input: data }),
     authMode: 'AMAZON_COGNITO_USER_POOLS',
   });

   createProductRequest
     .then(() => {
       alert('Product created!');
     })
     .catch(() => alert('There was an error creating the product'))
     .finally(() => setLoading(false));
 };

Here on product the data to be created is prepared, then on createProduct the API mutation promise is prepared. You can see that authMode was added, this is because if you are using multiple authentication methods you have to specify which method to use in the request.

Finally, the promise is handled if it succeeds, the user is redirected to the list of products and a message is shown. If it fails an alert is presented.

Here you can see the full example using react-hook-form and it's really interesting how the generated types can be used to create the form and provide immediate validation:

[imports ...]

Amplify.configure(awsExports);
function App() {
const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm<CreateProductInput>();
const { signOut } = useAuthenticator((context) => [context.user]);
const [isLoading, setLoading] = useState(false);
const addProduct = (data: CreateProductInput) => {
  setLoading(true);
  const createProductRequest: Promise<GraphQLResult<any>> = API.graphql({
    ...graphqlOperation(createProduct, { input: data }),
    authMode: 'AMAZON_COGNITO_USER_POOLS',
  });
  createProductRequest
    .then(() => {
      alert('Product created!');
    })
    .catch(() => alert('There was an error creating the product'))
    .finally(() => setLoading(false));
};
return (
  <AppLayout
    navigation={
      <>
        <SpaceBetween direction="vertical" size="l">
          <h1>T-shirts</h1>
          <Button onClick={signOut} variant="primary">
            Sign Out
          </Button>
        </SpaceBetween>
      </>
    }
    content={
      <Form
        actions={
          <Button
            loading={isLoading}
            formAction="submit"
            onClick={() => handleSubmit(addProduct)()}
            variant="primary"
          >
            Create Product
          </Button>
        }
      >
        <SpaceBetween direction="vertical" size="l">
          <Container
            header={
              <Header variant="h3">
                Complete the following form and add the variants:
              </Header>
            }
          >
            <SpaceBetween direction="vertical" size="l">
              <TextField
                label="Name"
                placeholder="T-shirt"
                {...register('name', { required: true })}
                hasError={errors.name && true}
                errorMessage="Product should be named"
              />
              <TextField
                label="Price"
                {...register('price', { required: true })}
                hasError={errors.price && true}
                errorMessage="Add a price"
              />
              
              <TextField
                label="Stock"
                {...register('options.0.stock', {
                  required: true,
                  min: 0,
                  max: 100,
                })}
                hasError={errors.options && true}
                errorMessage="Specify the stock"
              />
            </SpaceBetween>
          </Container>
        </SpaceBetween>
      </Form>
    }
  />
);
}
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
  <Authenticator.Provider>
    <Authenticator>
      <App />
    </Authenticator>
  </Authenticator.Provider>
</React.StrictMode>
);

API mutations:

You can create an abstraction of all the API mutations to reuse them, heare are some examples:

Create:

export const addElement = <T>(data: T, createMutation: string) => {
 const createElement: Promise<GraphQLResult<any>> = API.graphql({
   ...graphqlOperation(createMutation, { input: data }),
   authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS,
 });
 return createElement;
};

Update:

export const updateElement = <T>(
 data: T,
 elementId: string,
 updateMutation: string
) => {
 const element: T = {
   ...data,
   id: elementId,
 };
 const updatedElement: Promise<GraphQLResult<any>> = API.graphql({
   ...graphqlOperation(updateMutation, { input: element }),
   authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS,
 });
 return updatedElement;
};

Delete:

export const deleteElement = (elementId: string, deleteMutation: string) => {
 const deleteElementResult: Promise<GraphQLResult<any>> = API.graphql({
   query: deleteMutation,
   variables: { input: { id: elementId } },
   authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS,
 });
 return deleteElementResult;
};

Get element:

export const getElement = (elementId: string, getQuery: string) => {
 const getElementResult: Promise<GraphQLResult<any>> = API.graphql({
   ...graphqlOperation(getQuery, { id: elementId }),
   authMode: GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS,
 });
 return getElementResult;
};

To see the full demo application of how to handle all the different CRUD operations with the Amplify API follow this link to check the repo. https://github.com/natar10/tshirts

Analyze the control access to the API according to user permissions

It’s worth creating this section to analyze the schema.graphql file and how it is handling the permissions.

Amplify has an @auth directive to configure the authorization rules for public, sign-in user, per-user, and per-user group data access. To apply this to the GraphQL schema add the @auth directive including the rules that the model needs, as follows:

amplify\backend\api\tshirts\schema.graphql

type Client @model @auth(rules: [{ allow: owner, provider: userPools }]) {
  id: ID!
  user: String!
  name: String!
  
}

allow: owner defines that only the user that owns this model can have access to it. provider: userPools defines the provider method to be userPools, which means that the user has to be authenticated to access this model.

amplify\backend\api\tshirts\schema.graphql

type Product @model @auth(rules: [
  { allow: public, operations: [read], provider: iam},
  { allow: owner, provider: userPools }
]) {
  id: ID!
  name: String!
  price: Float!
  
}

For the product, the rule needs to be different. The product catalog should be public, but only for reading. The rules can be defined by the type of operation so allow: public allows public access, operations: [read] the type of operation for this rule, and provider: iam allows unauthenticated users to access the information.

amplify\backend\api\tshirts\schema.graphql

input AMPLIFY { globalAuthRule: AuthRule = { allow: public } }

If you are starting and just want to test your application, Amplify defines this rule by default. So every model can be publicly accessible to everyone.

To check more options, go to the AWS authorization rules docs here.

Demo source code:

Check the full source code of this part of the tutorial here: https://github.com/natar10/tshirts-amplify/tree/part-3-add-api

Next steps:

  • Part 4: (coming soon) Hosting and CI/CD

So far, this tutorial has explored how to scaffold a React application, install and configure amplify, add authentication, add the API and how to use it. Now let’s assume that the application is ready and needs to be deployed somewhere. Amplify can provide an integrated CI/CD workflow with Github and storage to deploy, you can check how to do this in Part 4 of this tutorial

Written by


Nataly Rocha

Nataly Rocha