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 and DATABASE_URL. We are going to deploy this app to Heroku. Heroku is smart enough to replace DATABASE_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 run heroku login

  1. heroku create your-heroku-app-name
  2. heroku buildpacks:set heroku/nodejs --app=your-heroku-app-name
  3. heroku addons:create heroku-postgresql:hobby-dev --app=your-heroku-app-name
  4. git status
  5. git commit -am "add any pending changes"
  6. git push heroku master
  7. heroku run npx sequelize-cli db:migrate
  8. 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!

https://your-heroku-app-name.herokuapp.com/graphql

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.

format_list_bulleted
help_outline