Software Development
Pawel Rybczynski, 2021-05-13

Deploy GraphQL/MongoDB API using Netlify Functions

In this article, we would like to present you how to configure Apollo servers (regular and lambda) to work with MongoDB. Then we’ll deploy our serverless API on Netlify. We decided to use Apollo GraphQL, MongoDB and Netlify because of their great docs and the fact that they are free for basic use.

Goals

  1. Configure both local and lambda servers.
  2. Connect both to MongoDB.
  3. Implement basic authentication.
  4. Deploy serverless Apollo GraphQL API with Netlify.
  5. Use Typescript.

Initial setup

npm init -y

Install dependencies

npm install --save typescript graphql aws-lambda @types/aws-lambda

Let's start

First, add the tsconfig.json to main directory:

{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "allowJs": true,
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/*.ts", "src/**/*.ts", "src/**/*.js"],
  "exclude": ["node_modules"]
}

Now, let's create src/server.ts for servers implementations. Then add two functions: one for local server and second for lambda.

// src/server.ts
import { ApolloServer as ApolloServerLambda } from "apollo-server-lambda";
import { ApolloServer } from "apollo-server";


const createLambdaServer = () =>
  new ApolloServerLambda({
    typeDefs,
    resolvers,
    introspection: true,
    playground: true,
    },
  });

const createLocalServer = () =>
  new ApolloServer({
    typeDefs,
    resolvers,
    introspection: true,
    playground: true,
    },
  });

export { createLambdaServer, createLocalServer };

OK, we don’t have any resolvers or type definitions so we need to create some. Let's assume that, at first, we want to create users and receive info about them.

// src/schemas.ts
const { gql } = require("apollo-server-lambda");

const userSchema = gql`
  type User {
    id: ID!
    email: String!
    name: String!
  }

  type Query {
    user(id: ID!): User!
  }

  type Mutation {
    createUser(name: String!, email: String!, password: String!): User!
  }
`;

If you’re not familiar with this, Apollo prepared a very nice tutorial

Now let’s create a user resolver with one query and one mutation.

// src/resolvers.ts
const userResolver = {
  Query: {
    user: async (parent, args, context, info) => {
      {...}
    },
  },
  Mutation: {
    createUser: async (parent, args, context, info) => {
      {...}
    },
  },
};

But we don’t have any data... Let's fix it ;)

Don’t forget to import type definition and resolver onto the server.

// src/server.ts
import { ApolloServer as ApolloServerLambda } from "apollo-server-lambda";
import { ApolloServer } from "apollo-server";

import { typeDefs } from "./schemas";
import { resolvers } from "./resolvers";

{...}

Connect with MongoDB via mongoose

Now it's a good time to create a connection with our database. In this particular case, it will be MongoDB. It’s free and easy to maintain. But before that, let's install two more dependencies:

npm install --save mongoose dotenv

The first step is to create a User Model.

// src/model.ts
import mongoose, { Document, Error, Schema } from "mongoose";

export type User = Document & {
  _id: string,
  email: string,
  name: string,
  password: string,
};

delete mongoose.connection.models["User"];

const UserSchema: Schema = new Schema({
  email: {
    type: String,
    required: true,
    unique: true,
  },
  name: {
    type: String,
    required: true,
    minLength: 3,
    maxLength: 32,
  },
  password: {
    type: String,
    required: true,
  },
});

export const userModel = mongoose.model < User > ("User", UserSchema);

Make passwords more safe

Security first! Let’s now secure our passwords by hashing them.

npm install --save bcrypt @types/bcrypt

Now, implement the password security inside the pre-middleware callback. Pre-middleware functions are executed one after another, when each middleware calls next. To make the passwords safe, we are using a technique which generates a salt and hash on separate function calls.

// src/model.ts
import bcrypt from "bcrypt";
{...}
const SALT_WORK_FACTOR: number = 10;

UserSchema.pre("save", function (next) {
  const user = this as User;
  if (!this.isModified("password")) return next();

  bcrypt.genSalt(SALT_WORK_FACTOR, function (err, salt) {
    if (err) return next(err);

    bcrypt.hash(user.password, salt, function (err, hash) {
      if (err) return next(err);
      user.password = hash;
      next();
    });
  });
});
{...}

Then add comparePasswords method to UserSchema:

// src/model.ts
{...}
UserSchema.methods.comparePasswords = function (
  candidatePassword: string,
  cb: (err: Error | null, same: boolean | null) => void
) {
  const user = this as User;
  bcrypt.compare(candidatePassword, user.password, (err, isMatch) => {
    if (err) {
      return cb(err, null);
    }
    cb(null, isMatch);
  });
};
{...}

And of course, modify User type.

type comparePasswordFunction = (
  candidatePassword: string,
  cb: (err: Error, isMatch: boolean) => void
) => void;

export type User = Document & {
  _id: string,
  email: string,
  name: string,
  password: string,
  comparePasswords: comparePasswordFunction,
};

Now we can arrange a connection between servers and databases. MONGODB_URI is an environment variable which contains a connection string needed to create the connection. You can get it from your cluster panel after you log into your MongoDB atlas account. Put it inside .env

// .env
MONGODB_URI = ...;

Always remember to add that file to .gitignore. Great! Now let's add a function which allow to connect with db.

// src/server.ts
import mongoose, { Connection } from "mongoose";
{...}
let cachedDb: Connection;

const connectToDatabase = async () => {
  if (cachedDb) return;

  await mongoose.connect(process.env.MONGODB_URI || "", {
    useNewUrlParser: true,
    useUnifiedTopology: true,
    useFindAndModify: false,
    useCreateIndex: true,
  });
  cachedDb = mongoose.connection;
};
{...}

The context is an object which is shared across all resolvers. To provide it, we just need to add a context initialization function to the ApolloServer constructor. Let's do it.

// src/server.ts
import { userModel } from "./models/user.model";
{...}
const createLambdaServer = async () =>
  new ApolloServerLambda({
    typeDefs,
    resolvers,
    introspection: true,
    playground: true,
    context: async () => {
      await connectToDatabase();

      return {
        models: {
          userModel,
        },
      };
    },
  });

const createLocalServer = () =>
  new ApolloServer({
    typeDefs,
    resolvers,
    introspection: true,
    playground: true,
    context: async () => {
      await connectToDatabase();

      return {
        models: {
          userModel,
        },
      };
    }
  });

As you can see, we are also passing userModel through context. We can now update resolver:

// resolvers.ts
const userResolver = {
  Query: {
    user: async (_, { email, name, password }, { models: { userModel } }) => {
      const user = await userModel.findById({ _id: id }).exec();
      return user;
    },
  },
  Mutation: {
    createUser: async (_, { id }, { models: { userModel } }) => {
      const user = await userModel.create({ email, name, password });
      return user;
    },
  },
};

Looks nice! Now create a basic server instance:

// src/index.ts
import { createLocalServer } from "./server";
require("dotenv").config();

const port = process.env.PORT || 4000;

const server = createLocalServer();

server.listen(port).then(({ url }) => {
  console.log(`Server ir running at ${url}`);
});

Starting the local server with Nodemon

Last thing before run, add nodemon.json

{
  "watch": ["src"],
  "ext": ".ts,.js",
  "ignore": [],
  "exec": "ts-node --transpile-only ./src/index.ts"
}

Add script to package.json

"scripts": {
    "start": "nodemon"
  },

And run!

npm start

You should get such info inside terminal:

Server ir running at http://localhost:4000/

If yes, open the http://localhost:4000/ inside your browser.

The GraphQL playground should appear. Let’s create a new user!

createUser.png

Take a look at how it looks in the database.

databaseJohnDoe.png

Cool! Everything works fine!

Let’s try and get some user info.

userWithoutAuth.png

And add the password field...

userPassword.png

Nice! We receive error Cannot query field \"password\" on type \"User\".". As you can check back, we didn't add this field inside the user type definition. It is there on purpose. We should not make it possible to query any password or other sensitive data.

Another thing... We can get user data without any authentication... it is not a good solution. We need to fix it.

But before...

Configure Codegen

Let’s use the GraphQL code generator to get a compatible base type, based on our schema.

npm install --save @graphql-codegen/cli @graphql-codegen/introspection
@graphql-codegen/typescript @graphql-codegen/typescript-resolvers

Create codegen.yml

overwrite: true
schema: "http://localhost:4000"
generates:
  ./src/generated/graphql.ts:
    plugins:
      - "typescript"
      - "typescript-resolvers"
  ./graphql.schema.json:
    plugins:
      - "introspection"

Add codegen script to package.json

"scripts": {
    "start": "nodemon",
    "codegen": "graphql-codegen --config ./codegen.yml",
  },

Then, when the local server is running, run script:

npm run codegen

In case of success you will get message:

Parse configuration
  √ Generate outputs

If you receive that info, two files should appear:

  • graphql.schema.json in the main directory
  • graphql.ts in newly created path src/generated

We are more interested in the second one. If you open it, you will notice a nice structure of types.

Now we can improve our resolvers:

// src/resolvers.ts
import { Resolvers, Token, User } from "./generated/graphql";

const userResolver: Resolvers = {
  Query: {
    user: async (_, { id }, { models: { userModel }, auth }): Promise<User> => {
      const user = await userModel.findById({ _id: id }).exec();
      return user;
    },
  },
  Mutation: {
    createUser: async (
      _,
      { email, name, password },
      { models: { userModel } }
    ): Promise<User> => {
      const user = await userModel.create({
        email,
        name,
        password,
      });
      return user;
    },
  },
};

Authentication

Next, let’s set up a simple token-based authentication.

npm install --save jsonwebtoken @types/jsonwebtoken

Create checkAuth function to verify if the token is valid. We will add result to the context so that we can access it inside the resolvers.

// src/server.ts
import { IncomingHttpHeaders } from "http";
import {
  APIGatewayProxyEvent,
  APIGatewayProxyEventHeaders,
  Context,
} from "aws-lambda";
import jwt from "jsonwebtoken";
{...}
const checkAuth = async ({ token } : APIGatewayProxyEventHeaders | IncomingHttpHeaders ) => {
  if (typeof token === "string") {
    try {
      return await jwt.verify(token, "riddlemethis");
    } catch (e) {
      throw new AuthenticationError(`Your session expired. Sign in again.`);
    }
  }
};
{...}
const createLambdaServer = async (
  { headers }: APIGatewayProxyEvent,
  context: Context
) => {
  return new ApolloServerLambda({
    typeDefs,
    resolvers,
    context: async () => {
      await connectToDatabase();

      const auth = await checkAuth(headers);

      return {
        auth,
        models: {
          userModel,
        },
      };
    },
  });
};

function createLocalServer() {
  return new ApolloServer({
    typeDefs,
    resolvers,
    introspection: true,
    playground: true,
    context: async ({ req: { headers } = {} }) => {
      const auth = await checkAuth(headers);
      await connectToDatabase();

      return {
         auth,
         models: {
          userModel,
        },
      };
    },
  });
}

Also, we need a way to create such a token. The best way is to implement a login query inside our resolver.

// resolvers.ts
import { Resolvers, Token, User } from "./generated/graphql";

const userResolver: Resolvers = {
  Query: {
    user: async (_, { id }, { models: { userModel }, auth }): Promise<User> => {
      if (!auth) throw new AuthenticationError("You are not authenticated");

      const user = await userModel.findById({ _id: id }).exec();
      return user;
    },
    login: async (
      _,
      { email, password },
      { models: { userModel } }
    ): Promise<Token> => {
      const user = await userModel.findOne({ email }).exec();

      if (!user) throw new AuthenticationError("Invalid credentials");

      const matchPasswords = bcrypt.compareSync(password, user.password);

      if (!matchPasswords) throw new AuthenticationError("Invalid credentials");

      const token = jwt.sign({ id: user.id }, "riddlemethis", {
        expiresIn: 60,
      });

      return { token };
    },
  },
  Mutation: {
    createUser: async (
      _,
      { email, name, password },
      { models: { userModel } }
    ): Promise<User> => {
      const user = await userModel.create({
        email,
        name,
        password,
      });
      return user;
    },
  },
};

You need also to update user type definitions by Token type and login query.

type Token {
    token: String!
  }

type Query {
    user(id: ID!): User!
    login(email: String!, password: String!): Token!
  }

Let's now try to get user without token

noAuth.png

OK, works fine! Try to log in

token.png

Let's try again to get user, but this time with token added to headers

withToken.png

Cool! And what if we set wrong credentials?

wrongEmail.png

Nice!

Prepare to Deploy

Last step: deploy serverless api with Netlify!

Create folder lambda in main dir and put two files inside:

First contains AWS handler. It create a ApolloServerLambda server instance and then expose a handler using createHandler of that instance.

// lambda/graphql.ts
import { APIGatewayProxyEvent, Context } from "aws-lambda";
import { createLambdaServer } from "???";

export const handler = async (
  event: APIGatewayProxyEvent,
  context: Context
) => {
  const server = await createLambdaServer(event, context);

  return new Promise((res, rej) => {
    const cb = (err: Error, args: any) => (err ? rej(err) : res(args));
    server.createHandler()(event, context, cb);
  });
};

You can read more about it.

Second one, is the tsconfig. Important part is the outDir field.

// lambda/tsconfig.json
{
  "compilerOptions": {
    "sourceMap": true,
    "noImplicitAny": true,
    "module": "commonjs",
    "target": "es6",
    "experimentalDecorators": true,
    "allowSyntheticDefaultImports": true,
    "moduleResolution": "node",
    "skipLibCheck": true,
    "esModuleInterop": true,
    "outDir": "./dist"
  },
  "include": ["./*.ts", "./**/*.ts", "./**/*.js"]
}

But there's a problem. We can't use /src dir because Netlify can't reach outside of the lambda folder. So we must bundle it.

npm install --save ncp

It`s a package that allows as to copy directory.

Add bundle script to package.json

"scripts": {
    "bundle": "ncp ./src ./lambda/bundle",
    "codegen": "graphql-codegen --config ./codegen.yml",
    "start": "nodemon",
  },

Now if you run npm run bundle, you san see, that in newly created lambda/bundle dir we have all files from src/.

Update the import path inside lambda/graphql.ts

// lambda/graphql.ts
import { APIGatewayProxyEvent, Context } from "aws-lambda";
import { createLambdaServer } from "./bundle/server";

{...}

You can now add lambda/bundle dir to .gitignore

Deploy with Netlify

We must to tell Netlify what the build command is and where our functions live. To to it let's create netlify.toml file:

// netlify.toml
[build]
  command = "npm run build:lambda"
  functions = "lambda/dist"

As you can see, it's same directory as defined as a outDir field in lambda/tsconfig.json

This is how your app structure should look like (well... part of it ;))

app
└───lambda
│ └────bundle
│ │ graphql.ts
│   tsconfig.json.ts
└───src
│ └───generated
│ │    │ graphql.ts
│ │ index.ts
│ │ model.ts
│ │ resolvers.ts
│ │ schemas.ts
│   server.ts
│
│ codegen.yml
│ graphql.schema.json
│ netlify.toml
│ nodemon.json
│ tsconfig.json

Add bundle:lambda script to package.json

"scripts": {
    "build:lambda": "npm run bundle && tsc -p lambda/tsconfig.json",
    "bundle": "ncp ./src ./lambda/bundle",
    "codegen": "graphql-codegen --config ./codegen.yml",
    "start": "nodemon",
  },

Deploy

Let’s try deploying our app via Netlify.

  1. Log to your Netlify account,
  2. Connect with your Github,
  3. Click 'New site from Git' button,
  4. Select a proper repo,
  5. Set the build command npm run build:lambda,
  6. Add environment variable (MONGODB_URI),
  7. Deploy...

And TAAADAAAA...

pageNotFound.png

That is because the endpoint is by default not the http://page/ but http://page/.netlify/functions/graphql.

withoutRedirect.png

How to fix it? It's very simple. Just create _redirects with:

/ /.netlify/functions/graphql 200!

Deploy again and check.

redirect.png

Hope you liked it! Feel free to improve and change.

Want to build or develop a digital product?

Read more:

How not to kill a project with bad coding practices?

Web app security. Target="_blank" vulnerability

Web app security - XSS vulnerability