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.
You will need Node, NPM, and PostgreSQL installed, and should have postgres running.
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.
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'.
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'
).
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.
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
.
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.
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);
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.
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.
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});
},
},
};
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};
},
});
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};
},
});
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};
},
});
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.