Skip to content

Latest commit

 

History

History
571 lines (439 loc) · 17.1 KB

getting-started.md

File metadata and controls

571 lines (439 loc) · 17.1 KB

Getting Started With Gestalt

This will walk you through creating a simple twitter clone from scratch using Gestalt. It assumes you are familiar with the basic ideas of GraphQL and Relay, as well as relational databases.

Prerequisites:

You will need Node, NPM, and PostgreSQL installed, and should have postgres running.

1) Install gestalt-cli

npm install --global gestalt-cli

gestalt-cli is a module that will help you scaffold new projects and run database migrations. Installing it globally with npm will allow you to use the gestalt command from the command line.

2) Create your database

createdb blogs

We will need a PostgreSQL database for our app to connect to. You can create one with the createdb command. Let's name it 'blogs'.

3) Create a new Gestalt project:

gestalt init blogs

This will create a new directory blogs, install gestalt-server, gestalt-postgres, and a few other necessary modules, and create the boilerplate files for a simple express app running a GraphQL API.

The Gestalt CLI will prompt you for a database url. Because we already created a database matching the name of our project ('blogs'), you can just hit enter to use the default url ('postgres://localhost/blogs').

4) Edit schema.graphql

gestalt init will have created a simple schema.graphql file with an object type named 'Session'.

type Session implements Node {
  id: ID!
}

We will use Session later, but for now we can ignore it. Let's add some types to our schema to represent users and posts:

type Session implements Node {
  id: ID!
}

type User implements Node {
  id: ID!
  email: String! @unique
  passwordHash: String! @hidden
}

type Post implements Node {
  id: ID!
  text: String!
  createdAt: Date!
}

This adds a type named User to our schema with fields id and email. We added the @unique directive to email to tell the database to enforce its uniqueness, and the @hidden directive to the passwordHash field to create a column in the database, but not a field in our GraphQL schema.

5) Run migrations

gestalt migrate

When you run gestalt-migrate in the root directory of your project, Gestalt reads the existing database schema, compares it to what is defined in schema.graphql, and generates a migration to update the database. This migration will add any new tables or columns (it avoids dropping anything for safety), and you can have the cli run it directly or write it to a file.

After our additions to schema.graphql, gestalt migrate will ask to confirm our database url, and then generate and print the following SQL migration:

CREATE EXTENSION IF NOT EXISTS "pgcrypto";

CREATE TABLE users (
  seq SERIAL NOT NULL UNIQUE,
  id uuid PRIMARY KEY,
  email text NOT NULL UNIQUE,
  password_hash text NOT NULL
);

CREATE TABLE posts (
  seq SERIAL NOT NULL UNIQUE,
  id uuid PRIMARY KEY,
  text text NOT NULL,
  created_at timestamp without time zone NOT NULL
);

A couple things are going on here - first Gestalt needs to add the 'pgcrypto' extension in order to generate secure uuids, and then we are creating tables for the two types we added, users and posts.

Looking first at the users table, the email and password hash columns are straightforward. We can see that the email column is UNIQUE because we added the @unique directive, and that both of the columns have type text corresponding to String in our GraphQL schema.

The id and seq columns are a little more surprising. You might expect a single column id SERIAL PRIMARY KEY for the id field. Instead id has type uuid, and we have an additional column seq with type SERIAL.

The reason we use UUIDs for database ids is that it lets us query nodes by id without needing to define extra permission checks. UUIDs generated by pgcrypto are securely random, so users will only be able to see nodes we deliberately expose to them.

And then because our ids are random, we include the seq column to record the order that rows were created so that we can order them chronologically.

You can type 'yes' to run the migration, and then 'no' to skip writing it to a file. At this point, you should be able to start the server and explore your schema in the GraphiQL IDE. Run npm start and navigate to localhost:3000/graphql. You can find the types you created by opening the 'docs' sidebar and clicking on QueryRoot, and then Node.

6) Add the AUTHORED relationship between users and posts:

We will also want to record the author of each post, and be able to see all of the posts authored by any user. We can add this to our schema using the @relationship directive.

type Session {
  id: ID!
}

type User implements Node {
  id: ID!
  email: String! @unique
  passwordHash: String! @hidden
  posts: Post @relationship(path: "=AUTHORED=>")
}

type Post implements Node {
  id: ID!
  text: String!
  createdAt: Date!
  author: User @relationship(path: "<-AUTHORED-")
}

Here we have added the posts field to the User type, and the author field to the Post type.

Gestalt will look at the @relationship directives on these fields, and match them because they have the same label, AUTHORED, and point in opposite directions.

The 'fat' arrow using the = character on the posts field indicates that the field is plural (a user can author many posts) and the 'skinny' arrow (using -) on the author field indicates that author is singular.

Gestalt can figure out that best way to represent this 'one to many' relationship is to add a foreign key to the posts table referencing users and storing the id of its author.

After making these changes to schema.graphql, run gestalt-migrate again. It will create the following migration:

ALTER TABLE posts ADD COLUMN authored_by_user_id uuid REFERENCES users (id);

CREATE INDEX ON posts (authored_by_user_id);

We can see that it's adding the foreign key column we expect, authored_by_user_id, and adding an index on it. Type 'yes' to run the migration.

We didn't need to run these migrations separately - we could have written the whole schema up front and ran gestalt migrate only once - but for the purposes of this introduction it's easier to present things one at a time.

After running the migration, if you restart your server you should see the posts field on User and author field on Posts show up in GraphiQL. One last thing you will notice is that the type of the posts field on User is PostsConnection and not Post. Gestalt creates connection and edge types types for fields with plural @relationship directives to allow pagination.

7) Add the FOLLOWED relationship between users:

The next relationship to add is FOLLOWED. Users can follow and be followed by many other users.

type User implements Node {
  ...
  followedUsers: User @relationship(path: "=FOLLOWED=>")
  followers: User @relationship(path: "<=FOLLOWED=")
 }

To represent this 'many to many' relationship, Gestalt will need to create a join table with columns for user_id and followed_user_id. Because the unique constraint on the table implies an index on user_id, Gestalt only needs to create an index on followed_user_id.

CREATE TABLE user_followed_users (
  user_id uuid NOT NULL REFERENCES users (id),
  followed_user_id uuid NOT NULL REFERENCES users (id),
  UNIQUE (user_id, followed_user_id)
);

CREATE INDEX ON user_followed_users (followed_user_id);

8) Add the feed field to User:

We use a final relationship to create the feed field - a list of all the posts authored by users that a user follows. We can express this really succinctly with the arrow syntax.

type User implements Node {
  ...
  feed: Post @relationship(path: "=FOLLOWED=>User=AUTHORED=>")
 }

Gestalt understands that our schema already accommodates this relationship, so it won't add any tables or columns.

9) Add a field to User with custom resolution

What if we have a field that needs some custom resolution? Maybe we want to return the url for a gravatar image based on a user's email address.

First, we will add the profileImage field to our user type. We can use the @virtual directive to make sure that it will be included in the GraphQL schema, but not result in a column being added to the database.

type User implements Node {
  ...
  profileImage: String! @virtual
}

Now that we have the field, we need to define custom resolution. To do it, we create a file User.js in the objects directory. Everything in objects will be imported automatically by the code in server.js.

import crypto from 'crypto';

export default {
  name: 'User',
  fields: {
    // get a user's gravatar image url using their email address
    profileImage: (obj, args, ctx) => {
      const email = obj.email.toLowerCase();
      const hash = crypto.createHash('md5').update(email).digest('hex');
      return `//www.gravatar.com/avatar/${hash}`;
    },
  },
};

Here we export an object with two properties. The first, name is the name of the type we want to define resolution for. The second fields is an object where the keys match names of fields on your type, and values are GraphQL resolution functions.

To create a Gravatar url, we lowercase the user's email and take its md5 hash. We return it interpolated into a string, and thats it! If you restart the server, you should see the profileImage field show up in GraphiQL.

10) Add currentUser to the Session type

OK - so we have a working API that will users and posts from our database. The next step is to allow users to sign in and out.

Remember the Session type we were going to come back to? This is where it comes in. In addition to querying nodes by ID, we can add fields to Session, and resolve them based on values we store in a session cookie.

Let's add a currentUser field to Session.

type Session implements Node {
  id: ID!
  currentUser: User
}

Because Session is not stored in the database, we will need to define custom resolution for any fields we add to it. Gestalt will have already created a Session.js file for us inside the objects directory, so we can just add the resolver for currentUser.

When a user signs in, we will set their id on the session as currentUserID. We will write code for this next, but for now let's just assume it will be set when a user is signed in.

First we will check if it has been set - if it has not, there is no currentUser and we can return null. Otherwise, we find the user by id from the database using context.db.findBy.

export default {
  name: 'Session',
  fields: {
    currentUser: (obj, args, context) => {
      if (obj.currentUserID == null) { return null; }
      return context.db.findBy('users', {id: obj.currentUserID});
    },
  },
};

9) create SignIn and SignOut mutations

To actually set (and remove) currentUserID from the session object, we use mutations. Mutations define the types of their inputs and the changed values they will return, and a function mutateAndGetPayload. Because these definitions depend on the types we have defined in schema.graphql, they are defined as a function of an object mapping all of the types in our schema by name.

This function returns a graphql-relay-js mutation config (with ond modification, types can be passed as values directly in inputFields and outputFields).

To log in, we expect email and password as input strings, and we will output the updated Session. We query for the user by id, compare the hashed password to the input using bcrpyt, and then update currentUserID and return the session.

If the db threw an error because no row matched email, or if bcrypt threw one because the passwords didn't match, we catch the error and rethrow with a descriptive message.

Create a file SignIn.js in the mutations directory, and add the following code

import bcrypt from 'bcrypt-as-promised';

export default types => ({
  name: 'SignIn',
  inputFields: {
    email: types.String,
    password: types.String,
  },
  outputFields: {
    session: types.Session,
  },
  mutateAndGetPayload: async (input, context) => {
    const {email, password} = input;
    const {db, session} = context;

    try {
      const user = await db.findBy('users', {email});
      await bcrypt.compare(password, user.passwordHash);
      session.currentUserID = user.id;
      return {session};
    } catch (e) {
      throw 'Email or password is invalid';
    }
  },
});

To sign out, we don't need any input, and will just set the session ID to null in mutateAndGetPayload. Create a file SignOut.js in the mutations directory, then add the following.

export default types => ({
  name: 'SignOut',
  inputFields: {},
  outputFields: {
    session: types.Session,
  },
  mutateAndGetPayload: (input, context) => {
    const {session} = context;
    session.currentUserID = null;
    return {session};
  },
});

10) create a SignUp mutation

To create users, we need another mutation. Follow the pattern above and create a file SignUp.js in the mutations directory. We can use assert to do some validation of our inputs, and then hash the password with bcrypt and insert the user into our database using context.db.insert.

import bcrypt from 'bcrypt-as-promised';
import assert from 'assert';

export default types => ({
  name: 'SignUp',
  inputFields: {
    email: types.String,
    password: types.String,
  },
  outputFields: {
    session: types.Session,
  },
  mutateAndGetPayload: async (input, context) => {
    const {email, password} = input;
    const {db, session} = context;

    assert(email.match(/.+@.+?\..+/), 'Email is invalid');
    assert(password.length > 5, 'Password is invalid');

    const passwordHash = await bcrypt.hash(password, 10);
    const user = await db.insert('users', {
      email,
      passwordHash,
    });

    session.currentUserID = user.id;
    return {session};
  },
});

11) Create FollowUser, UnfollowUser, and CreatePost mutations

Finally, we need mutations to follow and unfollow users, and to create posts. Create the corresponding files in the mutations directory.

export default types => ({
  name: 'FollowUser',
  inputFields: {
    userID: types.ID,
  },
  outputFields: {
    user: types.User,
    currentUser: types.User,
  },
  mutateAndGetPayload: async (input, context) => {
    const {db, session} = context;
    const {currentUserID} = session;

    // Node ids have the format `${type}:${databaseId}` - we split on ':' to
    // grab the database id of the user to follow.
    const followedUserID = input.userID.split(':')[1];

    await db.exec(
      'INSERT INTO user_followed_users (user_id, followed_user_id) ' +
      'VALUES ($1, $2);',
      [currentUserID, followedUserID]
    );

    const currentUser = await db.findBy('users', {id: currentUserID});
    const user = await db.findBy('users', {id: followedUserID});

    return {currentUser, user};
  },
});
export default types => ({
  name: 'UnfollowUser',
  inputFields: {
    userID: types.ID,
  },
  outputFields: {
    user: types.User,
    currentUser: types.User,
  },
  mutateAndGetPayload: async (input, context) => {
    const {db, session} = context;
    const {currentUserID} = session;

    // Node ids have the format `${type}:${databaseId}` - we split on ':' to
    // grab the database id of the user to follow.
    const followedUserID = input.userID.split(':')[1];

    await db.deleteBy(
      'user_followed_users',
      {userId: currentUserID, followedUserID}
    );

    const currentUser = await db.findBy('users', {id: currentUserID});
    const user = await db.findBy('users', {id: followedUserID});

    return {currentUser, user};
  },
});
import assert from 'assert';

export default types => ({
  name: 'CreatePost',
  inputFields: {
    title: types.String,
    text: types.String,
  },
  outputFields: {
    user: types.User,
  },
  mutateAndGetPayload: async (input, context) => {
    const {title, text} = input;
    const {db, session} = context;
    const {currentUserID} = session;

    assert(title.length > 0, 'Posts must have titles');
    assert(text.length > 0, 'Posts must have text');

    const user = await db.findBy('users', {id: currentUserID});

    const post = await db.insert('posts', {
      createdAt: new Date(),
      authoredByUserID: currentUserID,
      title,
      text,
    });

    return {user};
  },
});

12) Create a front end

Now that you have a complete API you could build a front end (or many!) on any platform(s) you like.

This step is beyond the scope of this walkthrough, but if you want to take a look at an example front end built using React and Relay, you can find one here.