Serverless computing provides us with benefits such as reduced operation cost and development time. It allows us focus on our code to provide business value to the users without worrying about building and maintaining servers. AWS is one of the many providers of serverless computing services. In this post, I'll show you how to create and consume a secured REST API.
Serverless is a cloud-computing execution model in which the cloud provider is responsible for executing a piece of code by dynamically allocating resources to run the code when needed. In a previous post, we looked at what serverless is, and we set up our computer to be able to build serverless applications using AWS Amplify. We bootstrapped a React project and added the Amplify library to it. In this post, we will use the Amplify CLI to provision a secured backend API and a NoSQL database. Then we will consume this API from the React project.
The application we're going to build will allow users to perform basic CRUD operation. We will use a REST API with a NoSQL database. Follow the instruction below to create the serverless backend.
amplify add api
.REST
and press Enter.todosApi
and press Enter.items
path by pressing Enter.Create a new Lambda function
and press Enter.todosLambda
as the name of the resource for the category (function category), and press Enter.todos
and press Enter.CRUD function for Amazon DynamoDB table (Integration with Amazon API Gateway and Amazon DynamoDB)
and press Enter. This creates an architecture using API Gateway with Express running in an AWS Lambda function that reads and writes to Amazon DynamoDB.Create a new DynamoDB table
option. Press Enter to continue. Now you should see DynamoDB database wizard. It'll ask a series of questions to determine how to create the database.todosTable
and press Enter.todos
and press Enter.id
with String
as its type.id
column when asked for the partition key (primary key) for the table.n
and press Enter. You should see the message Successfully added DynamoDb table locally
n
and press Enter. You should see the message Successfully added the Lambda function locally
.y
and press Enter.Authenticated and Guest users
and press Enter. This option gives both authorized and guest users access to the REST API.What kind of access do you want for Authenticated users
. Choose read/write
and press Enter.read
and press Enter. You should get the message Successfully added auth resource locally
. This is because we have chosen to restrict access to the API, and the CLI added the Auth category to the project since we don't have any for the project. At this point, we've added resources that are needed to create our API (API Gateway, DynamoDB, Lambda function, and Cognito for authentication).n
and press Enter. This completes the process and we get the message Successfully added resource todosApi locally
.The amplify add api
command took us through the process of creating a REST API. This API will be created based on the options we chose. To create this API requires 4 AWS services. They're:
todos
when we added the todosTable
resource. We gave it 3 columns with id
as the primary key.todosApi
, with a path items
. We also selected the option to restrict access to the API.However, the service specifications for this services are not yet in the cloud. We need to update the project in the cloud with information to provision the needed services. Run the command amplify status
, and we should get a table with information about the amplify project.
Category | Resource name | Operation | Provider plugin |
---|---|---|---|
Storage | todosTable | Create | awscloudformation |
Function | todosLambda | Create | awscloudformation |
Auth | cognitodc1bbadf | Create | awscloudformation |
Api | todosApi | Create | awscloudformation |
It lists the category we added along with the resource name and operation needed to be run for that resource. What the Create
operation means is that these resources need to be created in the cloud. The init
command goes through a process to generate the .amplifyrc file (it is written to the root directory of the project) and inserts a amplify folder structure into the project’s root directory, with the initial project configuration information written in it. Open the amplify folder and you'll find folders named backend and #current-cloud-backend. The backend folder contains the latest local development of the backend resources specifications to be pushed to the cloud, while #current-cloud-backend contains the backend resources specifications in the cloud from the last time the push
command was run. Each resources stores contents in its own subfolder inside this folder.
Open the file backend/function/todosLambda/src/app.js. You will notice that this file contains code generated during the resource set up process. It uses Express.js to set up routes, and aws-serverless-express package to easily build RESTful APIs using the Express.js framework on top of AWS Lambda and Amazon API Gateway. When we push the project configuration to the cloud, it'll configure a simple proxy API using Amazon API Gateway and integrate it with this Lambda function. The package includes middleware to easily get the event object Lambda receives from API Gateway. It was applied on line 32 app.use(awsServerlessExpressMiddleware.eventContext());
and used across the routes with codes that looks like req.apiGateway.event.*
. The pre-defined routes allow us to perform CRUD operation on the DynamoDB table. We will make a couple of changes to this file. The first will be to change the value for tableName
variable from todosTable
to todos
. When creating the DynamoDB resource, we specified todosTable
as the resource name and todos
as the table name, so it wrongly used the resource name as the table name when the file was created. This would likely be fixed in a future version of the CLI, so if you don't find it wrongly used, you can skip this step. We will also need to update the definitions.
Change the first route definition to use the code below.
app.get(path, function(req, res) {
const queryParams = {
TableName: tableName,
ProjectionExpression: "id, title"
};
dynamodb.scan(queryParams, (err, data) => {
if (err) {
res.json({ error: "Could not load items: " + err });
} else {
res.json(data.Items);
}
});
});
This defines a route to respond to the /items path with code to return all data in the DynamoDB table. The ProjectionExpression
values are used to specify that it should get only the columns id
and title
.
Change the route definition on line 77 to read as app.get(path + hashKeyPath + sortKeyPath, function(req, res) {
. This allows us retrieve an item by its id
following the path /items/:id. Also change line 173 to be app.delete(path + hashKeyPath + sortKeyPath, function(req, res) {
. This respnds to HTTP DELETE method to delete an item following the path /items/:id.
The AWS resources have been added and updated locally, and we need to provision them in the cloud. Open the command line and run amplify push
. You'll get a prompt if you want to continue executing the command. Enter y
and press Enter. What this does is that it'll upload the latest versions of the resources nested stack templates to an S3 deployment bucket, and then call the AWS CloudFormation API to create / update resources in the cloud.
When the amplify push
command completes, you'll see a file aws-exports.js in the src folder. This file contains information to the resources that were created in the cloud. Each time a resource is created or updated by running the push
command, this file will be updated. It's created for JavaScript projects and will be used in Amplify JavaScript library. We will be using this in our React project. We will also use Bootstrap to style the page. Open public/index.html and add the following in the head:
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO"
crossorigin="anonymous"
/>
<script
src="https://code.jquery.com/jquery-3.3.1.slim.min.js"
integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo"
crossorigin="anonymous"
></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"
integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49"
crossorigin="anonymous"
></script>
<script
src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
integrity="sha384-ChfqqxuZUCnJSK3+MXmPNIyE6ZbWh2IMqE241rYiqJxyMiZ6OW/JmZQ5stwEULTy"
crossorigin="anonymous"
></script>
Add a new file src/List.js with the following content:
import React from "react";
export default props => (
<div>
<legend>List</legend>
<div className="card" style={{ width: "25rem" }}>
{renderListItem(props.list, props.loadDetailsPage)}
</div>
</div>
);
function renderListItem(list, loadDetailsPage) {
const listItems = list.map(item => (
<li
key={item.id}
className="list-group-item"
onClick={() => loadDetailsPage(item.id)}
>
{item.title}
</li>
));
return <ul className="list-group list-group-flush">{listItems}</ul>;
}
This component will render a list of items from the API. Add a new file src/Details.js with the following content:
import React from "react";
export default props => (
<div>
<h2>Details</h2>
<div className="btn-group" role="group">
<button
type="button"
className="btn btn-secondary"
onClick={props.loadListPage}
>
Back to List
</button>
<button
type="button"
className="btn btn-danger"
onClick={() => props.delete(props.item.id)}
>
Delete
</button>
</div>
<legend>{props.item.title}</legend>
<div className="card">
<div className="card-body">{props.item.content}</div>
</div>
</div>
);
This component will display the details of an item with buttons to delete that item or go back to the list view. Open src/App.js and update it with this code:
import React, { Component } from "react";
import List from "./List";
import Details from "./Details";
import Amplify, { API } from "aws-amplify";
import aws_exports from "./aws-exports";
import { withAuthenticator } from "aws-amplify-react";
Amplify.configure(aws_exports);
class App extends Component {
constructor(props) {
super(props);
this.state = {
content: "",
title: "",
list: [],
item: {},
showDetails: false
};
}
async componentDidMount() {
await this.fetchList();
}
handleChange = event => {
const id = event.target.id;
this.setState({ [id]: event.target.value });
};
handleSubmit = async event => {
event.preventDefault();
await API.post("todosApi", "/items", {
body: {
id: Date.now().toString(),
title: this.state.title,
content: this.state.content
}
});
this.setState({ content: "", title: "" });
this.fetchList();
};
async fetchList() {
const response = await API.get("todosApi", "/items");
this.setState({ list: [...response] });
}
loadDetailsPage = async id => {
const response = await API.get("todosApi", "/items/" + id);
this.setState({ item: { ...response }, showDetails: true });
};
loadListPage = () => {
this.setState({ showDetails: false });
};
delete = async id => {
//TODO: Implement functionality
};
render() {
return (
<div className="container">
<form onSubmit={this.handleSubmit}>
<legend>Add</legend>
<div className="form-group">
<label htmlFor="title">Title</label>
<input
type="text"
className="form-control"
id="title"
placeholder="Title"
value={this.state.title}
onChange={this.handleChange}
/>
</div>
<div className="form-group">
<label htmlFor="content">Content</label>
<textarea
className="form-control"
id="content"
placeholder="Content"
value={this.state.content}
onChange={this.handleChange}
/>
</div>
<button type="submit" className="btn btn-primary">
Submit
</button>
</form>
<hr />
{this.state.showDetails ? (
<Details
item={this.state.item}
loadListPage={this.loadListPage}
delete={this.delete}
/>
) : (
<List list={this.state.list} loadDetailsPage={this.loadDetailsPage} />
)}
</div>
);
}
}
export default withAuthenticator(App, true);
We imported the Amplify library and initialised it by calling Amplify.configure(aws_exports);
. When the component is mounted, we call fetchList()
to retrieve items from the API. This function uses the API client from the Amplify library to call the REST API. Under the hood, it utilizes Axios to execute the HTTP requests. It'll add necessary headers to the request so you can successfully call the REST API. You can add headers if you defined custom headers for your API, but for our project, we only specify the apiName and path when invoking the functions from the API client. The loadDetailsPage()
function fetches a particular item from the database through the API and then sets item
state with the response and showDetails
to true. This showDetails
is used in the render function to toggle between showing a list of items or the details page of a selected item. The function handleSubmit()
is called when the form is submitted. It sends the form data to the API to create a document in the database, with columns id
, title
and content
, then calls fetchList()
to update the list. I left the delete()
function empty so you can implement it yourself. What better way to learn than to try it yourself 😉. This function will be called from the delete button in the Details
component. The code you have in it should call the API to delete an item by id
and display the list component with correct items. We wrapped the App component with the withAuthenticator
higher order component from the Amplify React library. This provides the app with complete flows for user registration, sign-in, signup, and sign out. Only signed in users can access the app since we're using this higher order component. The withAuthenticator
component automatically detects the authentication state and updates the UI. If the user is signed in, the underlying App component is displayed, otherwise, sign-in/signup controls are displayed. The second argument which was set to true
tells it to display a sign-out button at the top of the page. Using the withAuthenticator
component is the simplest way to add authentication flows into your app but you can also have a custom UI and use set of APIs from the Amplify library to implement sign-in and sign up flows. See the docs for more details.
We have all the code necessary to use the application. Open the terminal and run npm start
to start the application. You'll need to signup and sign in to use the application.
We went through creating our backend services using the Amplify CLI. The command amplify add api
took us adding resources for DynamoDB, Lambda, API Gateway, and Cognito for authentication. We updated the code in backend/function/todosLambda/src/app.js to match our API requirement. We added UI components to perform CRUD operations on the app and used a higher order component from the Amplify React library to allow only authenticated users access to the application. You should notice we only used a few lines of code to add authentication flows and call the API. Also creating the serverless backend services and connecting them all together was done with a command and responding to the prompts that followed. Thus showing how AWS Amplify makes development easier.