Chapter 11: Movies API
Full Stack React Native
link Movies API
By the end of this lesson, developers will be able to:
- Structure a GraphQL authentication server
- Create a GraphQL API with associations
Express API Structure (Authentication and Associations)
For the final project, we are going to build an API with authentication and associations. This means the API will be able to add users, and users will be able to add votes. The objects in our database will be able to reference each other.
The server will be responsible for four unique models:
- User
- Category
- Movie
- Vote
link Associations (ERD)
With any large-scale app server, it's important to denote the relationships between each entity. The following diagram resembles the structure of our server's relationships. This diagram is known as an Entity Relationship Diagram, or ERD.
Learn more about Entity Relationship Diagrams here
link Authentication (JWT)
Once we have set up our server with respective relationships, we can focus on GraphQL authentication. Users will obtain and send JSON Web Tokens, or JWT (pronounced "JOT"). These tokens will serve as verfication for users.
Learn more about JSON Web Tokens here
Let's get started!
We shall begin building our GraphQL server using npm init -y
:
mkdir movies-api
cd movies-api
npm init -y
Then, switch into your new directory and install the following dependencies:
cd movies-api
yarn add apollo-server-express express body-parser cors graphql graphql-tools bcrypt jsonwebtoken sequelize pg && yarn add -D sequelize-cli nodemon
Next we will initialize a Sequelize project:
npx sequelize-cli init
Let's setup our database configuration:
movies-api/config/config.json
{
"development": {
"database": "movies_api_development",
"dialect": "postgres"
},
"test": {
"database": "movies_api_test",
"dialect": "postgres"
},
"production": {
"use_env_variable": "DATABASE_URL",
"dialect": "postgres",
"dialectOptions": {
"ssl": true
}
}
}
Notice: For production we use
use_env_variable
andDATABASE_URL
. We are going to deploy this app to Heroku. Heroku is smart enough to replaceDATABASE_URL
with the production database. You will see this at the end of the lesson.
Cool, we should now be all setup to create the database. Let's do that:
npx sequelize-cli db:create
link Users and Categories
Next we will create a User model:
npx sequelize-cli model:generate --name User --attributes username:string,email:string,password_digest:string
Checkout the Sequelize Data Types that are available: https://sequelize.org/master/manual/data-types.html
Next we will create a Category model:
npx sequelize-cli model:generate --name Category --attributes title:string
Now we need to execute our migration which will create the Users and Categories tables in our Postgres database along with their columns:
npx sequelize-cli db:migrate
If you made a mistake, you can always rollback:
npx sequelize-cli db:migrate:undo
Seed Categories
Now let's create a seed file:
npx sequelize-cli seed:generate --name categories
Let's edit the code for the categories seed file
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Categories', [{
"title": 'Action',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Adventure',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Animation',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Comedy',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Drama',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Fantasy',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'History',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Horror',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Mystery',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Romance',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Music',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Science Fiction',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Thriller',
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": 'Western',
"createdAt": new Date(),
"updatedAt": new Date(),
}], {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Categories', null, {});
}
};
Execute the seed file:
npx sequelize-cli db:seed:all
Made a mistake? You can always undo:
npx sequelize-cli db:seed:undo
Drop into psql and query the database for the demo categories:
psql movies_api_development
SELECT * FROM "Categories";
link Movies and Votes
We are halfway there! There are two more models to generate: Movie and Vote.
- User
- Category
- Movie
- Vote
Next we will create a Movie model:
npx sequelize-cli model:generate --name Movie --attributes title:string,description:string,imageUrl:string,categoryId:integer
Next we will create a Vote model:
npx sequelize-cli model:generate --name Vote --attributes userId:integer,movieId:integer
Every model is generated, now we would like to associate them.
link One to Many
Now for our first associations, we will create several "One to Many" relationships. This will allow one model to reference many models.
One to Many (User < Vote)
For one user, we will allow it to reference many votes.
Make the following changes to models/user.js
:
'use strict';
module.exports = (sequelize, DataTypes) => {
const User = sequelize.define('User', {
username: DataTypes.STRING,
email: DataTypes.STRING,
password_digest: DataTypes.STRING
}, {});
User.associate = function(models) {
// associations can be defined here
User.hasMany(models.Vote, {
foreignKey: 'userId',
as: 'votes'
});
};
return User;
};
One to Many (Category < Movie)
For one category, we will allow it to reference many movies.
Make the following changes to models/category.js
:
'use strict';
module.exports = (sequelize, DataTypes) => {
const Category = sequelize.define('Category', {
title: DataTypes.STRING,
}, {});
Category.associate = function(models) {
// associations can be defined here
Category.hasMany(models.Movie, {
foreignKey: 'categoryId',
as: 'movies',
});
};
return Category;
};
One to Many to Many (Category < Movie < Vote)
For one movie, we will allow it to reference many votes. We also allow one category to reference many movies.
Open the models/movie.js
file, and make the following changes:
'use strict';
module.exports = (sequelize, DataTypes) => {
const Movie = sequelize.define('Movie', {
title: DataTypes.STRING,
description: DataTypes.STRING,
imageUrl: DataTypes.STRING,
categoryId: DataTypes.INTEGER
}, {});
Movie.associate = function(models) {
// associations can be defined here
Movie.hasMany(models.Vote, {
foreignKey: 'movieId',
as: 'votes',
});
Movie.belongsTo(models.Category, {
foreignKey: 'categoryId',
as: 'category',
});
};
return Movie;
};
We take an extra precaution to generate the categoryId
column in our database. Open migrations/<TIMESTAMP>-create-movie.js
, and make the following changes:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Movies', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
title: {
type: Sequelize.STRING
},
description: {
type: Sequelize.STRING
},
imageUrl: {
type: Sequelize.STRING
},
+ categoryId: {
+ type: Sequelize.INTEGER,
+ onDelete: 'CASCADE',
+ references: {
+ model: 'Categories',
+ key: 'id',
+ as: 'categoryId'
+ }
+ },
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Movies');
}
};
Open migrations/<TIMESTAMP>-create-vote.js
and make the following changes:
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Votes', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
+ userId: {
+ type: Sequelize.INTEGER,
+ onDelete: 'CASCADE',
+ references: {
+ model: 'Users',
+ key: 'id',
+ as: 'userId'
+ }
+ },
+ movieId: {
+ type: Sequelize.INTEGER,
+ onDelete: 'CASCADE',
+ references: {
+ model: 'Movies',
+ key: 'id',
+ as: 'movieId'
+ }
+ },
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Votes');
}
};
One movie now references many votes, and one category references many movies.
One to Many (Movie > Vote < User)
Votes are a unique entity, since they are "children" of two "parent" entities. So we create the associations for their references.
Add the following changes to models/vote.js
:
'use strict';
module.exports = (sequelize, DataTypes) => {
const Vote = sequelize.define('Vote', {
userId: DataTypes.INTEGER,
movieId: DataTypes.INTEGER
}, {});
Vote.associate = function(models) {
// associations can be defined here
Vote.belongsTo(models.User, {
foreignKey: 'userId',
onDelete: 'CASCADE',
as: 'user'
});
Vote.belongsTo(models.Movie, {
foreignKey: 'movieId',
onDelete: 'CASCADE',
as: 'movie'
});
};
return Vote;
};
Now we need to execute our migration which will create the Movie and Vote tables in our Postgres database along with their columns:
npx sequelize-cli db:migrate
If you made a mistake, you can always rollback:
npx sequelize-cli db:migrate:undo
link Seed Movies
Now let's create a seed file for movies:
npx sequelize-cli seed:generate --name movies
Let's edit the code for the movies seed file
'use strict';
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.bulkInsert('Movies', [
{
"title": "Frozen II",
"imageUrl": "https://image.tmdb.org/t/p/w780/qdfARIhgpgZOBh3vfNhWS4hmSo3.jpg",
"description": "Elsa, Anna, Kristoff and Olaf are going far in the forest to know the truth about an ancient mystery of their kingdom..",
"categoryId": 3,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "Spider-Man: Far From Home",
"imageUrl": "https://image.tmdb.org/t/p/w780/lcq8dVxeeOqHvvgcte707K0KVx5.jpg",
"description": "Peter Parker and his friends go on a summer trip to Europe. However, they will hardly be able to rest - Peter will have to agree to help Nick Fury uncover the mystery of creatures that cause natural disasters and destruction throughout the continent.",
"categoryId": 1,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "Joker",
"imageUrl": "https://image.tmdb.org/t/p/w780/udDclJoHjfjb8Ekgsd4FDteOkCU.jpg",
"description": "During the 1980s, a failed stand-up comedian is driven insane and turns to a life of crime and chaos in Gotham City while becoming an infamous psychopathic crime figure.",
"categoryId": 8,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "Once Upon a Time... in Hollywood",
"imageUrl": "https://image.tmdb.org/t/p/w780/8j58iEBw9pOXFD2L0nt0ZXeHviB.jpg",
"description": "A faded television actor and his stunt double strive to achieve fame and success in the film industry during the final years of Hollywood's Golden Age in 1969 Los Angeles.",
"categoryId": 14,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "The Lion King",
"imageUrl": "https://image.tmdb.org/t/p/w780/2bXbqYdUdNVa8VIWXVfclP2ICtT.jpg",
"description": "Simba idolizes his father, King Mufasa, and takes to heart his own royal destiny. But not everyone in the kingdom celebrates the new cub's arrival. Scar, Mufasa's brother and a former heir to the throne has plans of his own.",
"categoryId": 3,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "Ford v Ferrari",
"imageUrl": "https://image.tmdb.org/t/p/w780/6ApDtO7xaWAfPqfi2IARXIzj8QS.jpg",
"description": "American car designer Carroll Shelby and the British-born driver Ken Miles work together to battle corporate interference, the laws of physics, and their own personal demons to build a revolutionary race car for Ford Motor Company.",
"categoryId": 13,
"createdAt": new Date(),
"updatedAt": new Date(),
},
{
"title": "Aladdin",
"imageUrl": "https://image.tmdb.org/t/p/w780/3iYQTLGoy7QnjcUYRJy4YrAgGvp.jpg",
"description": "A kindhearted street urchin named Aladdin embarks on a magical adventure after finding a lamp that releases a wisecracking genie while a power-hungry Grand Vizier vies for the same lamp that has the power to make their deepest wishes come true.",
"categoryId": 3,
"createdAt": new Date(),
"updatedAt": new Date(),
}
], {});
},
down: (queryInterface, Sequelize) => {
return queryInterface.bulkDelete('Movies', null, {});
}
};
Execute the seed file:
npx sequelize-cli db:seed:all
Made a mistake? You can always undo:
npx sequelize-cli db:seed:undo
Drop into psql and query the database for the demo categories:
psql movies_api_development
SELECT * FROM "Movies";
If you see a list of movies, well done!!
Let's continue with a .gitignore
file:
touch .gitignore
Paste in the following inside .gitignore
:
/node_modules
.DS_Store
.env
Enough Sequelize! Let's add authentication to our GraphQL server.
link JWT
Let's create a new utils.js
for checking auth tokens. JWT will verify the current token and return a matching user's ID.
mkdir resolvers
cd resolvers
touch utils.js
Paste the following code inside utils.js
:
const jwt = require('jsonwebtoken');
const APP_SECRET = 'React-Native-GraphQL';
function getUserId(context) {
const authorization = context.req.headers['authorization'];
try {
if (authorization) {
const token = authorization.replace('Bearer ', '');
const user = jwt.verify(token, APP_SECRET);
return user.id;
}
} catch (err) {
throw new Error(err.message);
}
}
module.exports = {
getUserId,
APP_SECRET,
}
link Resolvers
Before moving onto the server boilerplate, we will create two new files for our GraphQL resolvers: Query.js
and Mutation.js
.
touch Query.js
touch index.js
mkdir Mutation
Queries
Add the following code to Query.js
:
const { User, Category, Movie, Vote, sequelize } = require('../models');
const { getUserId } = require('./utils');
const feed = async (_, args, context) => {
const {categoryId} = args;
const movies = await Movie.findAll({
where: categoryId ? {categoryId} : {},
include: [
{
model: Vote,
as: 'votes',
include: [{
model: User,
as: 'user',
}]
},
{
model: Category,
as: 'category'
}
]
});
return movies;
};
const categories = async (_, args, context) => {
const categories = await Category.findAll({
where: {},
});
return categories;
};
const currentUser = async (_, args, context) => {
const userId = getUserId(context);
if (userId) {
const user = await User.findOne({
where: { id: userId },
include: [
{
model: Vote,
as: 'votes',
include: [{
model: Movie,
as: 'movie',
include: [{
model: Category,
as: 'category',
}]
}]
},
]
});
return user;
}
};
module.exports = {
feed,
categories,
currentUser,
}
Mutations
Our schema uses two unique libraries: jsonwebtoken
and bcrypt
. Let's dig into their details:
Now cd
into the Mutation
directory and create a new file called auth.js
:
touch auth.js
Insert the following code inside auth.js
:
const { User, sequelize } = require('../../models');
const { APP_SECRET } = require('../utils');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const auth = {
async signUp(_, { username, email, password }) {
try {
const password_digest = await bcrypt.hash(password, 10);
const user = await User.create({
username,
email,
password_digest
});
const payload = {
id: user.id,
username: user.username,
email: user.email
};
const token = jwt.sign(payload, APP_SECRET);
return { user, token };
} catch (err) {
throw new Error(err.message);
}
},
async signIn(_, { username, email, password }) {
try {
if (username || email) {
const user = username
? await User.findOne({
where: {
username
}
})
: await User.findOne({
where: {
email
}
});
if (await bcrypt.compare(password, user.dataValues.password_digest)) {
const payload = {
id: user.id,
username: user.username,
email: user.email
};
const token = jwt.sign(payload, APP_SECRET);
return { user, token };
}
throw new Error('Invalid credentials');
}
throw new Error('Invalid credentials');
} catch (err) {
throw new Error(err.message);
}
},
}
module.exports = { auth }
Create one more new file called vote.js
:
touch vote.js
Insert the following inside vote.js
:
const { User, Vote, sequelize } = require('../../models');
const { getUserId } = require('../utils');
const vote = {
async addVote(_, { movieId }, context) {
const userId = getUserId(context);
if (userId) {
const user = await User.findOne({
where: { id: userId },
include: [
{
model: Vote,
as: 'votes'
}
]
});
if (user.votes.find(vote => vote.movieId == movieId)) {
throw new Error('Cannot vote twice');
}
const newVote = await Vote.create({ userId, movieId });
return newVote.id;
}
throw new Error('Not authorized');
},
async removeVote(_, { movieId }, context) {
const userId = getUserId(context);
const vote = await Vote.findOne({ where: { userId, movieId } });
if (userId && vote && userId == vote.userId) {
const deleted = await vote.destroy();
if (deleted) {
return vote.id;
}
throw new Error('Not authorized');
}
throw new Error('Vote not found');
},
}
module.exports = { vote }
Now, cd ..
up one directory, and insert the following inside index.js
:
const Query = require('./Query')
const { auth } = require('./Mutation/auth')
const { vote } = require('./Mutation/vote')
module.exports = {
Query,
Mutation: {
...auth,
...vote,
},
}
link Apollo Server
Now back to our GraphQL server code. Here is the boilerplate:
const { ApolloServer, gql } = require('apollo-server-express');
const { createServer } = require('http');
const { makeExecutableSchema } = require('graphql-tools');
const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const resolvers = require('../resolvers')
const { User, Movie, Vote, sequelize } = require('../models');
const port = process.env.PORT || 4000;
const typeDefs = gql`
type Query {
currentUser: User
feed(categoryId: ID): [Movie!]!
categories: [Category!]!
}
type Mutation {
signUp(email: String!, password: String!, username: String!): AuthPayload
signIn(email: String, username: String, password: String!): AuthPayload
addVote(movieId: ID!): ID
removeVote(movieId: ID!): ID
}
type AuthPayload {
token: String
user: User
}
type User {
id: ID!
username: String!
email: String!
votes: [Vote!]!
}
type Category {
id: ID!
title: String!
description: String
}
type Movie {
id: ID!
title: String!
description: String!
category: Category
imageUrl: String!
votes: [Vote!]!
}
type Vote {
id: ID!
movie: Movie
user: User
}
`;
const schema = makeExecutableSchema({
typeDefs,
resolvers
});
const apolloServer = new ApolloServer({
schema,
context: request => {
return {
...request
};
},
introspection: true,
playground: {
endpoint: '/graphql'
}
});
const app = express();
const server = createServer(app);
apolloServer.applyMiddleware({ app });
app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());
app.use(cors());
server.listen({port}, () => console.log(`Server is running at http://localhost:${port}/graphql`))
link Test Server
Modify your package.json file to support nodemon. Also, let's create a command npm db:reset
that will drop the database, create the database, run the migrations, and seed!
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon src/index.js",
"db:reset": "npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npx sequelize-cli db:seed:all"
},
Let's make sure our server works:
yarn start
Test Query
Open http://localhost:4000/graphql, and paste in the following snippet:
query {
feed {
title
description
imageUrl
category {
title
}
}
}
There should be a list of movies, each with a nested category.
Test Mutation
To create a new User, paste the following snippet inside the GraphQL playground:
mutation SignUp($username: String!, $email: String!, $password: String!) {
signUp(username: $username, email: $email, password: $password) {
token
user {
id
username
email
}
}
}
At the bottom left corner of the page, click "Query Variables" and insert the following snippet:
{"username": "alice", "password": "password", "email": "alice@gmail.com"}
Execute the mutation, and you should see a long token
string, along with the new user's information.
We now have an API with Authentication functionality built in.
Well Done!
link Deployment
Let's deploy our app to heroku.
First we need to update our package.json:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"db:reset": "npx sequelize-cli db:drop && npx sequelize-cli db:create && npx sequelize-cli db:migrate && npx sequelize-cli db:seed:all"
},
Make sure you download the
heroku cli
and runheroku login
heroku create your-heroku-app-name
heroku buildpacks:set heroku/nodejs --app=your-heroku-app-name
heroku addons:create heroku-postgresql:hobby-dev --app=your-heroku-app-name
git status
git commit -am "add any pending changes"
git push heroku master
heroku run npx sequelize-cli db:migrate
heroku run npx sequelize-cli db:seed:all
Having issues? Debug with the Heroku command
heroku logs --tail
to see what's happening on the Heroku server.
Test the endpoints!
Wrapping Up
Today we created an GraphQL server with authentication. We used associations to create and reference model relationships. The next chapter continues with the movie app's network requests.