REST has been dominating the API design architecture for over a decade. In recent years, developers are looking for an alternative that provides faster development, greater flexibility and simplified data management.
GraphQL, created by Facebook in 2012, has become one of the most popular REST alternatives. From the 2019 state of JS survey, 30.7% of the developers have used GraphQL and would use it again. Compared with other data layer technologies, it is ranked as having the greatest awareness, interest, and satisfaction. It allows clients to define their desired response data structure, reducing overall fetch time and development cost.
Apollo is a platform that implements GraphQL and provides both server-side and client-side libraries for your app. Serverless is a framework that simplifies the deployment of serverless technologies, such as AWS Lambda. For this how-to guide, we will be building a GraphQL API using Apollo, deploying it to an AWS Lambda using the Serverless Framework.
Building the Application
The sample application we will be building is a web app that will present a user with details and workout videos based on which muscle the user selects in an anatomy diagram. The app has a React client that communicates to the backend through GraphQL (Apollo) through an AWS API Gateway and Lambda. Data will be stored in a MySQL RDS database.
We will create the sample app step by step. The app source code could be found here and final results will look like this.
AWS account and database set up
This application requires an AWS account. Regardless of whether you need to create a new account or are reusing an existing account, this application stays within AWS’ free tier. If you need to create an account, you can follow this link.
After your registration, we will use the AWS web console to create a MySQL RDS database for the app. This RDS instance will serve as the persistence layer for our Apollo server. Also note that Apollo server can use other data storage means in AWS, such as DynamoDB.
To create your database, you can choose the easy create method to make sure you are still within the free tier range.
After the database is created, you will be able to access it through the AWS CLI or a database client tool. Since it’s a mysql database, I am using Mysql workbench to connect. The user and password are the ones that you generated at creation and the host and port can be found under Connectivity & Security tab.
Before you connect for the first time, you will need to edit the inbound rules on EC2→Security Groups to make sure that it is open to your local IP address. I am setting the source to 0.0.0.0/0 for simplicity, allowing access from any IP address. While this is OK for prototyping, this is NOT suitable for production applications.
Now you are able to connect the database locally. Feel free to set up the database following the diagram or just simply import from the sql file.
Creating the Apollo Server
Make sure that you have node and npm installed. In order to automatically restart your server application while making code changes, I also recommend installing nodemon.
1. Create a new node project
$ npm init
2. Install Apollo server dependencies
$ npm install --save apollo-server graphql
3. Add a new index file ./src/index.js and create the ApolloServer instance.
const { ApolloServer } = require('apollo-server'); const server = new ApolloServer({}); // The `listen` method launches a web server. server.listen().then(({ url }) => { console.log(`Muscle server ready at ${url}`); });
4. Start the server
Run $ nodemon src/index.js to start the Apollo server. The default port is 4000.
5. Add schemas
Schema describes the shape of your data graph. It defines the type system, and which queries and mutations can be executed against the backend.
Let’s create a schema file ./src/schema.js. We will define object types for our muscles and videos. Object types represent a kind of object you can fetch from a data source and contains a collection of fields. Each field is usually a built-in scala type. For example, the id ID below is a scala type and it’s a unique identifier for the object, often used to refetch an object or as the key of a cache. The ID is serialized as a String type but is not intended to be human readable. ! means that the field is required and cannot be null. The field can also be a defined type like videos: [Video] below, which declares videos to be an array of Videos.
const { gql } = require('apollo-server'); const typeDefs = gql` type Muscle { id: ID! name: String description: String videos: [Video] } type Video { id: ID! name: String description: String videoLink: String imageLink: String } `; module.exports = typeDefs;
A schema also specifies which queries and mutations are available for clients to execute against your data graph. A Query type contains a collection of fields that a user can fetch against the data graph. A Mutation type is similar to Query type, but it enables the user to modify the data.
Every GraphQL Service has a Query type, but a Mutation type is optional. For our sample app, we will only add Query type.
Below, we define four queries for clients to execute: Muscles, Muscle, Videos and Video. For example, muscles query will return an array of Muscles. muscle query returns a single Muscle based on id argument. The id can not be null and needs to be the scalar ID type.
const { gql } = require('apollo-server'); const typeDefs = gql` type Muscle { id: ID! name: String description: String videos: [Video] } type Video { id: ID! name: String description: String videoLink: String imageLink: String } type Query { muscles: [Muscle]! muscle(id: ID!): Muscle videos: [Video]! video(id: ID!): Video } `; module.exports = typeDefs;
Don’t forget to import the schemas to our index.js.
const { ApolloServer } = require('apollo-server'); const typeDefs = require('./schema'); const muscleDatabase = require("./datasources/muscle"); const server = new ApolloServer({ typeDefs }); // The `listen` method launches a web server. server.listen().then(({ url }) => { console.log(`Muscle server ready at ${url}`); });
6. Add data source
Apollo server can fetch data from restful APIs, micro services or directly from databases. In this example, we will use the MySQL RDS database created above. I’m using a library named datasource-sql, which is contributed by the Apollo community. It simplifies querying a database. The library combines the power of Knex as the SQL query builder. Knex not only provides common functions like select, join, and where, but also supports options like nestTables to get a query result in a nested object format if set to true.
Install the dependency
$ npm install --save datasource-sql
Create a datasource file ./src/datasources/muscle.js and add the methods to fetch the muscles and videos.
const { SQLDataSource } = require('datasource-sql'); class MuscleDatabase extends SQLDataSource { getMuscles() { return this.knex .select('*') .from('muscles'); } getMuscleById(id) { return this.knex .select('*') .from('muscles AS muscle') .leftJoin('muscleVideos AS mv', 'mv.muscleId', 'muscle.id') .leftJoin('videos AS video', 'mv.videoId', 'video.id') .where('muscle.id', '=', id) .options({nestTables: true}) .then(results => { return this.muscleReducer(results); }); } getVideoById(id) { return this.knex .select('*') .from('videos AS video') .leftJoin('muscleVideos AS mv', 'mv.videoId', 'video.id' ) .leftJoin('muscles AS muscle', 'mv.muscleId', 'muscle.id') .where('video.id', '=', id) .options({nestTables: true}) .then(results => { return this.videoReducer(results); }); } muscleReducer(muscles) { if(muscles.length > 0) { const videos = muscles.map(muscle => muscle.video) return { ...muscles[0].muscle, videos }; } return null; } videoReducer(videos) { if(videos.length > 0) { const muscles = videos.map(video => video.muscle) return { ...videos[0].video, muscles }; } return null; } } module.exports = MuscleDatabase;
We also need to add the database connections and datasource to our index.js.
const { ApolloServer } = require('apollo-server'); const typeDefs = require('./schema'); const muscleDatabase = require("./datasources/muscle"); const knexConfig = { client: 'mysql', connection: { host : 'your db host name', user : 'your db user', password : 'your db password', database : 'muscle' } }; const server = new ApolloServer({ typeDefs, dataSources: () => ({ muscleDatabase: new muscleDatabase(knexConfig) }) }); // The `listen` method launches a web server. server.listen().then(({ url }) => { console.log(`Muscle server ready at ${url}`); });
7. Add Resolvers
The last step we need to add to make the server functional are resolvers which are responsible for populating the data for a single field in your schema.
Create a resolver file ./src/resolver.js.
module.exports = { Query: { muscles:(_, {}, { dataSources }) => dataSources.muscleDatabase.getMuscles(), muscle: (_, { id }, { dataSources }) => dataSources.muscleDatabase.getMuscleById(id), video: (_, { id }, { dataSources }) => dataSources.muscleDatabase.getVideoById(id), } };
Don’t forget to import it in index.js.
const { ApolloServer } = require('apollo-server'); const typeDefs = require('./schema'); const muscleDatabase = require("./datasources/muscle"); const resolvers = require('./resolvers'); const knexConfig = { client: 'mysql', connection: { host : 'your db host name', user : 'your db user', password : 'your db password', database : 'muscle' } }; const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ muscleDatabase: new muscleDatabase(knexConfig) }) }); // The `listen` method launches a web server. server.listen().then(({ url }) => { console.log(`Muscle server ready at ${url}`); });
At this point, we should be able to query video and muscle by id on http://localhost:4000. Open up localhost on your browser and you will be able to query the data on the GraphQL Playground.
Deploy Apollo Server with AWS Lambda
1. Install AWS CLI if you haven’t
3. Install the serverless globally
$ npm install -g serverless
4. Add serverless.yml at the root of the project to config the serverless
# serverless.yml service: apollo-lambda provider: name: aws runtime: nodejs12.x functions: graphql: # this is formatted as <FILENAME>.<HANDLER> handler: src/graphql.graphqlHandler events: - http: path: graphql method: post cors: true - http: path: graphql method: get cors: true
5. Install apollo-server-lambda to your project
npm install --save apollo-server-lambda
6. Add ./src/graphql.js to the project.
It has to be the exact name graphql.js for severless to pick it up. This file is really similar to the index.js, but it exports a graphqlHander with a lambda function handler and also creates an endpoint for the playground.
const { ApolloServer } = require('apollo-server-lambda'); const typeDefs = require('./schema'); const muscleDatabase = require("./datasources/muscle"); const resolvers = require('./resolvers'); const knexConfig = { client: 'mysql', connection: { host : 'your db host name', user : 'your db user', password : 'your db password', database : 'muscle' } }; const server = new ApolloServer({ typeDefs, resolvers, dataSources: () => ({ muscleDatabase: new muscleDatabase(knexConfig) }), playground: { endpoint: "/dev/graphql" } }); exports.graphqlHandler = server.createHandler({ cors: { origin: '*', credentials: false, }, });
7. Deploy the Apollo server by running serverless
$ sls deploy
You should be getting the output from the console that giving you the endpoints for query
Serverless: Packaging service... Serverless: Excluding development dependencies... Serverless: Uploading CloudFormation file to S3... Serverless: Uploading artifacts... Serverless: Uploading service apollo-lambda.zip file to S3 (9.25 MB)... Serverless: Validating template... Serverless: Updating Stack... Serverless: Checking Stack update progress... .............. Serverless: Stack update finished... Service Information service: apollo-lambda stage: dev region: us-east-1 stack: apollo-lambda-dev resources: 13 api keys: None endpoints: POST - url for post GET - url for get functions: graphql: apollo-lambda-dev-graphql layers: None Serverless: Removing old service artifacts from S3... Serverless: Run the "serverless" command to setup monitoring, troubleshooting and testing.
8. Test
You should be able to see the function in AWS lambda
If you click on the function name, you can see the API gateway associated with it. And the endpoint should be the same url from your serverless console.
You can also find the different versions of the lambda function you have deployed in S3.
You are able to access the GraphQL Playground by the Api endpoint + /dev/graphql. You can also test it through Postman.
Install the latest Postman. (It started supporting GraphQl from v7.2)
Creating the React Apollo Client
For the client project, I’m using the React framework. Apollo conveniently provides an Apollo client library for React and also for other popular SPA frameworks like Angular and Vue.js.
The application is very simple; it contains two pages: home(muscle) page and video detail page.
Home page allows users to click on a single muscle on the body svg and show the corresponding muscle details and video list. Each video on the list directs users to a video detail page.
1. Create react app
npx create-react-app client --template typescript
2. Install Apollo packages
npm install --save apollo-boost @apollo/react-hooks graphql
3. Add environment files to the root of the folder to define Api endpoints. At /client/.env, add
REACT_APP_API = “http://localhost:4000/grapql” // localhost api endpoint
4. Create Apollo Client at /client/src/index.tsx
import React from 'react'; import ReactDOM from 'react-dom'; import ApolloClient from 'apollo-boost'; import { ApolloProvider } from '@apollo/react-hooks'; const client = new ApolloClient({ uri: process.env.REACT_APP_API, }); const App = () => ( <ApolloProvider client={client}> <div><h2>My Muscle App</h2></div> </ApolloProvider> ); ReactDOM.render(<App />, document.getElementById('root'));
Run npm run start and you should be able to see a basic app running on http://localhost:3000
5. Add Home Page
a. Define the muscle and video type
./client/src/types/muscle.tsx
import {Video} from './video'; export interface Muscle { id?: string; name?: string; description?: string; videos?: [ Video ]; }
./client/src/types/video.tsx
export interface Video { id?: string; name?: string; description?: string; imageLink?: string; videoLink?: string; }
b. Add Muscle Detail Component
Let’s create the components that the muscle page needs.
Add ./client/src/components/muscle-detail.tsx
Apollo clients provide useQuery hook from @apollo/react-hooks which is an api that executes queries and shares GraphQL data with the UI. We will pass a gql getting muscle detail query string to the userQuery and it returns an object that contains loading, error and data if successful. Apollo Client kindly maintains the state for you.
import React, { Fragment } from 'react'; import { useQuery } from '@apollo/react-hooks'; import gql from 'graphql-tag'; import { Muscle } from '../types/muscle'; import { VideoMeta } from './index'; export const GET_MUSCLE_DETAIL = gql` query MuscleDetail($muscleId: ID!) { muscle(id: $muscleId) { name description videos { id name imageLink videoLink } } } `; interface MuscleId { muscleId?: number } interface MuscleRespond { muscle: Muscle } const MuscleDetail: React.FC<MuscleId> = ({muscleId}) => { const { data, loading, error } = useQuery< MuscleRespond , MuscleId >(GET_MUSCLE_DETAIL, { variables: { muscleId } } ); let videoDom; if (loading) return <p>Loading...</p>; if (error) return <p>ERROR: {error.message}</p>; if (!data || !data.muscle) return <p>Not found</p>; if (data.muscle && data.muscle.videos) { videoDom = <div className="videos-list"> <p><b>Videos</b></p> {data.muscle.videos.map((value) => { return <VideoMeta {...value}></VideoMeta> })} </div> } return ( <Fragment> <h2>{ data.muscle.name }</h2> <p>{ data.muscle.description}</p> {videoDom} </Fragment> ) } export default MuscleDetail;
We can see there’s a VideoMeta component missing which renders a list of videos related to the specific muscle.
Add ./client/src/components/video-meta.tsx. Each video-meta block has a link to the corresponding video detail page. I’m using Material UI for grid and react-router-dom for routing so install them first:
npm install --save @material-ui/core react-router-dom
import React from 'react'; import { Video } from '../types/video'; import Grid from '@material-ui/core/Grid'; import { Link } from 'react-router-dom'; const VideoMeta: React.FC<Video> = (video) => { return ( <Grid container spacing={1} className="video-meta"> <Grid item sm={4} xs={12} md={4}> <Link to={'/video/'+video.id}><img src={video.imageLink} alt={video.name}></img></Link> </Grid> <Grid item sm={8} xs={12} md={8}> <h3><Link to={'/video/'+video.id}>{video.name}</Link></h3> <p>{video.description}</p> </Grid> </Grid> ) } export default VideoMeta;
c. Create Body Image Component
This component contains an svg of the whole body and allows users to click on each muscle piece. The page is large, but can be referenced here.
d. Add an index file to export all the components.
./client/src/components/index.tsx
export { default as MuscleDetail } from './muscle-detail'; export { default as VideoMeta } from './video-meta'; export { default as BodyImage } from './body-image';
e. Create Home Page Component
Add ./client/src/pages/muscle.tsx. The muscle id is emitted from the body-image component and the corresponding muscle data will be fetched by the muscle-detail component.
import React, { Component } from 'react'; import { MuscleDetail, BodyImage } from '../components'; import Grid from '@material-ui/core/Grid'; interface MusclePageProps {} interface MusclePageState { muscleId?: number; } class MusclePage extends Component<MusclePageProps, MusclePageState> { constructor(props: MusclePageProps) { super(props); this.state = {} this.onMuscleClick = this.onMuscleClick.bind(this); } component = this; onMuscleClick(muscleId: number) { this.setState( state => ( { muscleId: muscleId } )); } render() { return ( <Grid container spacing={2} className="muscle-page-container"> <Grid item xs={12} sm={12} md={6}> <BodyImage {...{muscleId: this.state.muscleId, onMuscleClick: this.onMuscleClick}}></BodyImage> </Grid> <Grid item xs={12} sm={12} md={6}> {this.state.muscleId && <MuscleDetail {... {muscleId: this.state.muscleId}} />} {!this.state.muscleId && <h2>Please select a muscle to start</h2> } </Grid> </Grid> ) } } export default MusclePage
6. Add Video Page
a. Add ./client/src/components/video-detail.tsx. It is similar to the muscle-detail component, except we are querying video by its id this time.
import React, { Fragment } from 'react'; import { useQuery } from '@apollo/react-hooks'; import gql from 'graphql-tag'; import { Video } from '../types/video'; export const GET_VIDEO_DETAIL = gql` query VideoDetail($videoId: ID!) { video(id: $videoId) { id name description videoLink } } `; interface VideoId { videoId?: number } interface VideoRespond { video: Video } const VideoDetail: React.FC<VideoId> = ({videoId}) => { const { data, loading, error } = useQuery< VideoRespond , VideoId >(GET_VIDEO_DETAIL, { variables: { videoId } } ); if(!videoId) return <h2>Please select a video to start</h2>; if (loading) return <p>Loading...</p>; if (error) return <p>ERROR: {error.message}</p>; if (!data || !data.video) return <p>Not found</p>; return ( <Fragment> <h1>{ data.video.name }</h1> <p>{ data.video.description }</p> <iframe title={data.video.name} width="420" height="315" src={ data.video.videoLink }> </iframe> </Fragment> ) } export default VideoDetail;
b. Add the component to the ./client/src/components/index.tsx
export { default as MuscleDetail } from './muscle-detail'; export { default as BodyImage } from './body-image'; export { default as VideoMeta } from './video-meta'; export { default as VideoDetail } from './video-detail';
c. Add ./client/src/pages/video.tsx
import React, { Component } from 'react'; import { VideoDetail } from '../components'; import Grid from '@material-ui/core/Grid'; import { Link } from 'react-router-dom'; interface VideoPageProps { match? :any } interface VideoPageState {} class VideoPage extends Component<VideoPageProps, VideoPageState> { render() { const videoId = this.props.match.params.id; return ( <Grid container className="video-page-container"> <Grid item xs={12} sm={12} md={12}> <Link to="/">Home</Link> <VideoDetail {...{videoId: videoId }} /> </Grid> </Grid> ) } } export default VideoPage
7. Add Routing to the app
Add a file for routing at /client/src/routing.tsx
The video detail page takes the video id as a url parameter.
import React from "react"; import { BrowserRouter as Router, Switch, Route, } from "react-router-dom"; import MusclePage from './pages/muscle'; import VideoPage from './pages/video'; export default function Routing() { return ( <Router> <Switch> <Route exact path="/"> <MusclePage /> </Route> <Route path="/video/:id" component={VideoPage} /> </Switch> </Router> ); }
Import the routing into ./client/src/index.tsx
import React from 'react'; import ReactDOM from 'react-dom'; import ApolloClient from 'apollo-boost'; import { ApolloProvider } from '@apollo/react-hooks'; import Routing from './routing' const client = new ApolloClient({ uri: process.env.REACT_APP_API, }); const App = () => ( <ApolloProvider client={client}> <Routing /> </ApolloProvider> ); ReactDOM.render(<App />, document.getElementById('root'));
8. Test
Now the whole app should be running on http://localhost:3000 !
Apollo provides Client Developer Tools, allowing you to inspect an element and see all the queries, mutations and cache.
Deploy Apollo Client
I am hosting the app simply on the github pages.
To deploy on github you will need to push the project to a repo first.
1. Configure github pages at the Setting tab of the repository.
2. Install gh-pages
npm install gh-pages --save-dev
3. Add following scripts to the package.json
"scripts": { "predeploy": "npm run build", "deploy": "gh-pages -d build" ... }
4. Add Homepage property to the package.json and assign it to your github page url
"homepage": "https://daituzhang.github.io/muscle-workout", // your url here
5. Add ./client/.env.production to set the production environment variables
REACT_APP_API = "https://nbysf9yfy1.execute-api.us-east-1.amazonaws.com/dev/graphql" // the endpoint to the AWS cloud front REACT_APP_PUBLIC_URL = "/muscle-workout" // public url for routing
We also need to add the public url variable to our local .env
REACT_APP_API = "http://localhost:4000/grapql" REACT_APP_PUBLIC_URL = ""
6. Add basename to your ./client/src/routing.tsx
import React from "react"; import { BrowserRouter as Router, Switch, Route, } from "react-router-dom"; import MusclePage from './pages/muscle'; import VideoPage from './pages/video'; export default function Routing() { return ( <Router basename={process.env.REACT_APP_PUBLIC_URL}> <Switch> <Route exact path="/"> <MusclePage /> </Route> <Route path="/video/:id" component={VideoPage} /> </Switch> </Router> ); }
7. Deploy the app to github pages
npm run deploy
8. Test
Make sure your github pages source is set to the gh-pages branch and your app is published.
Congratulations! You have finished the serverless app end to end by using React, Apollo, AWS Lambda and RDS.
The source code is here for your reference and feel free to comment or create issues if you have any questions.