├── .babelrc ├── .github └── FUNDING.yml ├── .gitignore ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── src ├── index.js ├── loaders ├── index.js └── user.js ├── models ├── index.js ├── message.js └── user.js ├── resolvers ├── authorization.js ├── index.js ├── message.js └── user.js ├── schema ├── index.js ├── message.js └── user.js ├── subscription ├── index.js └── message.js └── tests ├── api.js └── user.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", { 5 | "targets": { 6 | "node": "current" 7 | } 8 | } 9 | ] 10 | ] 11 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: rwieruch 4 | patreon: # rwieruch 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | logfile 2 | 3 | .env 4 | 5 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 70 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - stable 5 | 6 | install: 7 | - npm install 8 | 9 | script: 10 | - npm test 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Robin Wieruch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fullstack-apollo-express-postgresql-boilerplate 2 | 3 | [![Build Status](https://travis-ci.org/the-road-to-graphql/fullstack-apollo-express-postgresql-boilerplate.svg?branch=master)](https://travis-ci.org/the-road-to-graphql/fullstack-apollo-express-postgresql-boilerplate) [![Slack](https://slack-the-road-to-learn-react.wieruch.com/badge.svg)](https://slack-the-road-to-learn-react.wieruch.com/) [![Greenkeeper badge](https://badges.greenkeeper.io/the-road-to-graphql/fullstack-apollo-express-postgresql-boilerplate.svg)](https://greenkeeper.io/) 4 | 5 | A full-fledged Apollo Server with Apollo Client starter project with React and Express. [Read more about it in this tutorial to build it yourself](https://www.robinwieruch.de/graphql-apollo-server-tutorial/). 6 | 7 | **Family of universal fullstack repositories:** 8 | 9 | Server Applications: 10 | 11 | * [Node.js with Express + MongoDB](https://github.com/the-road-to-graphql/fullstack-apollo-express-mongodb-boilerplate) 12 | * [Node.js with Express + PostgreSQL](https://github.com/the-road-to-graphql/fullstack-apollo-express-postgresql-boilerplate) 13 | 14 | Client Applications: 15 | 16 | * [React Client](https://github.com/the-road-to-graphql/fullstack-apollo-react-boilerplate) 17 | * [React Native Client](https://github.com/morenoh149/fullstack-apollo-react-native-boilerplate) 18 | 19 | ## Features of Client + Server 20 | 21 | * React (create-react-app) with Apollo Client 22 | * Queries, Mutations, Subscriptions 23 | * Node.js with Express and Apollo Server 24 | * cursor-based Pagination 25 | * PostgreSQL Database with Sequelize 26 | * entities: users, messages 27 | * Authentication 28 | * powered by JWT and local storage 29 | * Sign Up, Sign In, Sign Out 30 | * Authorization 31 | * protected endpoint (e.g. verify valid session) 32 | * protected resolvers (e.g. e.g. session-based, role-based) 33 | * protected routes (e.g. session-based, role-based) 34 | * performance optimizations 35 | * example of using Facebook's dataloader 36 | * E2E testing 37 | 38 | ## Installation 39 | 40 | * `git clone git@github.com:the-road-to-graphql/fullstack-apollo-express-postgresql-boilerplate.git` 41 | * `cd fullstack-apollo-express-postgresql-boilerplate` 42 | * `touch .env` 43 | * `npm install` 44 | * fill out *.env file* (see below) 45 | * start PostgreSQL database 46 | * `npm start` 47 | * visit `http://localhost:8000` for GraphQL playground 48 | 49 | #### .env file 50 | 51 | Since this boilerplate project is using PostgreSQL, you have to install it for your machine and get a database up and running. You find everything for the set up over here: [Setup PostgreSQL with Sequelize in Express Tutorial](https://www.robinwieruch.de/postgres-express-setup-tutorial). After you have created a database and a database user, you can fill out the environment variables in the *server/.env* file. 52 | 53 | ``` 54 | DATABASE=mydatabase 55 | 56 | DATABASE_USER=postgres 57 | DATABASE_PASSWORD=postgres 58 | 59 | SECRET=asdlplplfwfwefwekwself.2342.dawasdq 60 | ``` 61 | 62 | The `SECRET` is just a random string for your authentication. Keep all these information secure by adding the *.env* file to your *.gitignore* file. No third-party should have access to this information. 63 | 64 | #### Testing 65 | 66 | * adjust `test:run-server` npm script with `TEST_DATABASE` environment variable in package.json to match your testing database name 67 | * to match it from package.json: `createdb mytestdatabase` with psql 68 | * one terminal: npm run test:run-server 69 | * second terminal: npm run test:execute-test 70 | 71 | ## Want to learn more about React + GraphQL + Apollo? 72 | 73 | * Don't miss [upcoming Tutorials and Courses](https://www.getrevue.co/profile/rwieruch) 74 | * Check out current [React Courses](https://roadtoreact.com) 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-apollo-express-postgresql-boilerplate", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "10.11.0" 8 | }, 9 | "scripts": { 10 | "start": "nodemon --exec babel-node src/index.js", 11 | "test:run-server": "TEST_DATABASE=mytestdatabase npm start", 12 | "test:execute-test": "mocha --require @babel/register 'src/**/*.spec.js'", 13 | "test": "echo \"No test specified\" && exit 0" 14 | }, 15 | "keywords": [], 16 | "author": "Robin Wieruch (https://www.robinwieruch.de)", 17 | "license": "ISC", 18 | "devDependencies": { 19 | "@babel/core": "^7.1.6", 20 | "@babel/node": "^7.0.0", 21 | "@babel/preset-env": "^7.1.6", 22 | "@babel/register": "^7.0.0", 23 | "axios": "^0.18.0", 24 | "chai": "^4.2.0", 25 | "mocha": "^5.2.0", 26 | "nodemon": "^1.18.7", 27 | "morgan": "^1.9.1" 28 | }, 29 | "dependencies": { 30 | "apollo-server": "^2.2.3", 31 | "apollo-server-express": "^2.2.3", 32 | "bcrypt": "^3.0.2", 33 | "cors": "^2.8.5", 34 | "dataloader": "^1.4.0", 35 | "dotenv": "^6.1.0", 36 | "express": "^4.16.4", 37 | "graphql": "^14.0.2", 38 | "graphql-iso-date": "^3.6.1", 39 | "graphql-resolvers": "^0.2.2", 40 | "jsonwebtoken": "^8.4.0", 41 | "pg": "^7.6.1", 42 | "sequelize": "^4.41.2", 43 | "uuid": "^3.3.2" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import cors from 'cors'; 3 | import morgan from 'morgan'; 4 | import http from 'http'; 5 | import jwt from 'jsonwebtoken'; 6 | import DataLoader from 'dataloader'; 7 | import express from 'express'; 8 | import { 9 | ApolloServer, 10 | AuthenticationError, 11 | } from 'apollo-server-express'; 12 | 13 | import schema from './schema'; 14 | import resolvers from './resolvers'; 15 | import models, { sequelize } from './models'; 16 | import loaders from './loaders'; 17 | 18 | const app = express(); 19 | 20 | app.use(cors()); 21 | 22 | app.use(morgan('dev')); 23 | 24 | const getMe = async req => { 25 | const token = req.headers['x-token']; 26 | 27 | if (token) { 28 | try { 29 | return await jwt.verify(token, process.env.SECRET); 30 | } catch (e) { 31 | throw new AuthenticationError( 32 | 'Your session expired. Sign in again.', 33 | ); 34 | } 35 | } 36 | }; 37 | 38 | const server = new ApolloServer({ 39 | introspection: true, 40 | playground: true, 41 | typeDefs: schema, 42 | resolvers, 43 | formatError: error => { 44 | // remove the internal sequelize error message 45 | // leave only the important validation error 46 | const message = error.message 47 | .replace('SequelizeValidationError: ', '') 48 | .replace('Validation error: ', ''); 49 | 50 | return { 51 | ...error, 52 | message, 53 | }; 54 | }, 55 | context: async ({ req, connection }) => { 56 | if (connection) { 57 | return { 58 | models, 59 | loaders: { 60 | user: new DataLoader(keys => 61 | loaders.user.batchUsers(keys, models), 62 | ), 63 | }, 64 | }; 65 | } 66 | 67 | if (req) { 68 | const me = await getMe(req); 69 | 70 | return { 71 | models, 72 | me, 73 | secret: process.env.SECRET, 74 | loaders: { 75 | user: new DataLoader(keys => 76 | loaders.user.batchUsers(keys, models), 77 | ), 78 | }, 79 | }; 80 | } 81 | }, 82 | }); 83 | 84 | server.applyMiddleware({ app, path: '/graphql' }); 85 | 86 | const httpServer = http.createServer(app); 87 | server.installSubscriptionHandlers(httpServer); 88 | 89 | const isTest = !!process.env.TEST_DATABASE; 90 | const isProduction = !!process.env.DATABASE_URL; 91 | const port = process.env.PORT || 8000; 92 | 93 | sequelize.sync({ force: isTest || isProduction }).then(async () => { 94 | if (isTest || isProduction) { 95 | createUsersWithMessages(new Date()); 96 | } 97 | 98 | httpServer.listen({ port }, () => { 99 | console.log(`Apollo Server on http://localhost:${port}/graphql`); 100 | }); 101 | }); 102 | 103 | const createUsersWithMessages = async date => { 104 | await models.User.create( 105 | { 106 | username: 'rwieruch', 107 | email: 'hello@robin.com', 108 | password: 'rwieruch', 109 | role: 'ADMIN', 110 | messages: [ 111 | { 112 | text: 'Published the Road to learn React', 113 | createdAt: date.setSeconds(date.getSeconds() + 1), 114 | }, 115 | ], 116 | }, 117 | { 118 | include: [models.Message], 119 | }, 120 | ); 121 | 122 | await models.User.create( 123 | { 124 | username: 'ddavids', 125 | email: 'hello@david.com', 126 | password: 'ddavids', 127 | messages: [ 128 | { 129 | text: 'Happy to release ...', 130 | createdAt: date.setSeconds(date.getSeconds() + 1), 131 | }, 132 | { 133 | text: 'Published a complete ...', 134 | createdAt: date.setSeconds(date.getSeconds() + 1), 135 | }, 136 | ], 137 | }, 138 | { 139 | include: [models.Message], 140 | }, 141 | ); 142 | }; 143 | -------------------------------------------------------------------------------- /src/loaders/index.js: -------------------------------------------------------------------------------- 1 | import * as user from './user'; 2 | 3 | export default { user }; 4 | -------------------------------------------------------------------------------- /src/loaders/user.js: -------------------------------------------------------------------------------- 1 | export const batchUsers = async (keys, models) => { 2 | const users = await models.User.findAll({ 3 | where: { 4 | id: { 5 | $in: keys, 6 | }, 7 | }, 8 | }); 9 | 10 | return keys.map(key => users.find(user => user.id === key)); 11 | }; 12 | -------------------------------------------------------------------------------- /src/models/index.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | 3 | let sequelize; 4 | if (process.env.DATABASE_URL) { 5 | sequelize = new Sequelize(process.env.DATABASE_URL, { 6 | dialect: 'postgres', 7 | }); 8 | } else { 9 | sequelize = new Sequelize( 10 | process.env.TEST_DATABASE || process.env.DATABASE, 11 | process.env.DATABASE_USER, 12 | process.env.DATABASE_PASSWORD, 13 | { 14 | dialect: 'postgres', 15 | }, 16 | ); 17 | } 18 | 19 | const models = { 20 | User: sequelize.import('./user'), 21 | Message: sequelize.import('./message'), 22 | }; 23 | 24 | Object.keys(models).forEach(key => { 25 | if ('associate' in models[key]) { 26 | models[key].associate(models); 27 | } 28 | }); 29 | 30 | export { sequelize }; 31 | 32 | export default models; 33 | -------------------------------------------------------------------------------- /src/models/message.js: -------------------------------------------------------------------------------- 1 | const message = (sequelize, DataTypes) => { 2 | const Message = sequelize.define('message', { 3 | text: { 4 | type: DataTypes.STRING, 5 | validate: { notEmpty: true }, 6 | }, 7 | }); 8 | 9 | Message.associate = models => { 10 | Message.belongsTo(models.User); 11 | }; 12 | 13 | return Message; 14 | }; 15 | 16 | export default message; 17 | -------------------------------------------------------------------------------- /src/models/user.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | 3 | const user = (sequelize, DataTypes) => { 4 | const User = sequelize.define('user', { 5 | username: { 6 | type: DataTypes.STRING, 7 | unique: true, 8 | allowNull: false, 9 | validate: { 10 | notEmpty: true, 11 | }, 12 | }, 13 | email: { 14 | type: DataTypes.STRING, 15 | unique: true, 16 | allowNull: false, 17 | validate: { 18 | notEmpty: true, 19 | isEmail: true, 20 | }, 21 | }, 22 | password: { 23 | type: DataTypes.STRING, 24 | allowNull: false, 25 | validate: { 26 | notEmpty: true, 27 | len: [7, 42], 28 | }, 29 | }, 30 | role: { 31 | type: DataTypes.STRING, 32 | }, 33 | }); 34 | 35 | User.associate = models => { 36 | User.hasMany(models.Message, { onDelete: 'CASCADE' }); 37 | }; 38 | 39 | User.findByLogin = async login => { 40 | let user = await User.findOne({ 41 | where: { username: login }, 42 | }); 43 | 44 | if (!user) { 45 | user = await User.findOne({ 46 | where: { email: login }, 47 | }); 48 | } 49 | 50 | return user; 51 | }; 52 | 53 | User.beforeCreate(async user => { 54 | user.password = await user.generatePasswordHash(); 55 | }); 56 | 57 | User.prototype.generatePasswordHash = async function() { 58 | const saltRounds = 10; 59 | return await bcrypt.hash(this.password, saltRounds); 60 | }; 61 | 62 | User.prototype.validatePassword = async function(password) { 63 | return await bcrypt.compare(password, this.password); 64 | }; 65 | 66 | return User; 67 | }; 68 | 69 | export default user; 70 | -------------------------------------------------------------------------------- /src/resolvers/authorization.js: -------------------------------------------------------------------------------- 1 | import { ForbiddenError } from 'apollo-server'; 2 | import { combineResolvers, skip } from 'graphql-resolvers'; 3 | 4 | export const isAuthenticated = (parent, args, { me }) => 5 | me ? skip : new ForbiddenError('Not authenticated as user.'); 6 | 7 | export const isAdmin = combineResolvers( 8 | isAuthenticated, 9 | (parent, args, { me: { role } }) => 10 | role === 'ADMIN' 11 | ? skip 12 | : new ForbiddenError('Not authorized as admin.'), 13 | ); 14 | 15 | export const isMessageOwner = async ( 16 | parent, 17 | { id }, 18 | { models, me }, 19 | ) => { 20 | const message = await models.Message.findById(id, { raw: true }); 21 | 22 | if (message.userId !== me.id) { 23 | throw new ForbiddenError('Not authenticated as owner.'); 24 | } 25 | 26 | return skip; 27 | }; 28 | -------------------------------------------------------------------------------- /src/resolvers/index.js: -------------------------------------------------------------------------------- 1 | import { GraphQLDateTime } from 'graphql-iso-date'; 2 | 3 | import userResolvers from './user'; 4 | import messageResolvers from './message'; 5 | 6 | const customScalarResolver = { 7 | Date: GraphQLDateTime, 8 | }; 9 | 10 | export default [ 11 | customScalarResolver, 12 | userResolvers, 13 | messageResolvers, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/resolvers/message.js: -------------------------------------------------------------------------------- 1 | import Sequelize from 'sequelize'; 2 | import { combineResolvers } from 'graphql-resolvers'; 3 | 4 | import pubsub, { EVENTS } from '../subscription'; 5 | import { isAuthenticated, isMessageOwner } from './authorization'; 6 | 7 | const toCursorHash = string => Buffer.from(string).toString('base64'); 8 | 9 | const fromCursorHash = string => 10 | Buffer.from(string, 'base64').toString('ascii'); 11 | 12 | export default { 13 | Query: { 14 | messages: async (parent, { cursor, limit = 100 }, { models }) => { 15 | const cursorOptions = cursor 16 | ? { 17 | where: { 18 | createdAt: { 19 | [Sequelize.Op.lt]: fromCursorHash(cursor), 20 | }, 21 | }, 22 | } 23 | : {}; 24 | 25 | const messages = await models.Message.findAll({ 26 | order: [['createdAt', 'DESC']], 27 | limit: limit + 1, 28 | ...cursorOptions, 29 | }); 30 | 31 | const hasNextPage = messages.length > limit; 32 | const edges = hasNextPage ? messages.slice(0, -1) : messages; 33 | 34 | return { 35 | edges, 36 | pageInfo: { 37 | hasNextPage, 38 | endCursor: toCursorHash( 39 | edges[edges.length - 1].createdAt.toString(), 40 | ), 41 | }, 42 | }; 43 | }, 44 | message: async (parent, { id }, { models }) => { 45 | return await models.Message.findById(id); 46 | }, 47 | }, 48 | 49 | Mutation: { 50 | createMessage: combineResolvers( 51 | isAuthenticated, 52 | async (parent, { text }, { models, me }) => { 53 | const message = await models.Message.create({ 54 | text, 55 | userId: me.id, 56 | }); 57 | 58 | pubsub.publish(EVENTS.MESSAGE.CREATED, { 59 | messageCreated: { message }, 60 | }); 61 | 62 | return message; 63 | }, 64 | ), 65 | 66 | deleteMessage: combineResolvers( 67 | isAuthenticated, 68 | isMessageOwner, 69 | async (parent, { id }, { models }) => { 70 | return await models.Message.destroy({ where: { id } }); 71 | }, 72 | ), 73 | }, 74 | 75 | Message: { 76 | user: async (message, args, { loaders }) => { 77 | return await loaders.user.load(message.userId); 78 | }, 79 | }, 80 | 81 | Subscription: { 82 | messageCreated: { 83 | subscribe: () => pubsub.asyncIterator(EVENTS.MESSAGE.CREATED), 84 | }, 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/resolvers/user.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken'; 2 | import { combineResolvers } from 'graphql-resolvers'; 3 | import { AuthenticationError, UserInputError } from 'apollo-server'; 4 | 5 | import { isAdmin, isAuthenticated } from './authorization'; 6 | 7 | const createToken = async (user, secret, expiresIn) => { 8 | const { id, email, username, role } = user; 9 | return await jwt.sign({ id, email, username, role }, secret, { 10 | expiresIn, 11 | }); 12 | }; 13 | 14 | export default { 15 | Query: { 16 | users: async (parent, args, { models }) => { 17 | return await models.User.findAll(); 18 | }, 19 | user: async (parent, { id }, { models }) => { 20 | return await models.User.findById(id); 21 | }, 22 | me: async (parent, args, { models, me }) => { 23 | if (!me) { 24 | return null; 25 | } 26 | 27 | return await models.User.findById(me.id); 28 | }, 29 | }, 30 | 31 | Mutation: { 32 | signUp: async ( 33 | parent, 34 | { username, email, password }, 35 | { models, secret }, 36 | ) => { 37 | const user = await models.User.create({ 38 | username, 39 | email, 40 | password, 41 | }); 42 | 43 | return { token: createToken(user, secret, '30m') }; 44 | }, 45 | 46 | signIn: async ( 47 | parent, 48 | { login, password }, 49 | { models, secret }, 50 | ) => { 51 | const user = await models.User.findByLogin(login); 52 | 53 | if (!user) { 54 | throw new UserInputError( 55 | 'No user found with this login credentials.', 56 | ); 57 | } 58 | 59 | const isValid = await user.validatePassword(password); 60 | 61 | if (!isValid) { 62 | throw new AuthenticationError('Invalid password.'); 63 | } 64 | 65 | return { token: createToken(user, secret, '30m') }; 66 | }, 67 | 68 | updateUser: combineResolvers( 69 | isAuthenticated, 70 | async (parent, { username }, { models, me }) => { 71 | const user = await models.User.findById(me.id); 72 | return await user.update({ username }); 73 | }, 74 | ), 75 | 76 | deleteUser: combineResolvers( 77 | isAdmin, 78 | async (parent, { id }, { models }) => { 79 | return await models.User.destroy({ 80 | where: { id }, 81 | }); 82 | }, 83 | ), 84 | }, 85 | 86 | User: { 87 | messages: async (user, args, { models }) => { 88 | return await models.Message.findAll({ 89 | where: { 90 | userId: user.id, 91 | }, 92 | }); 93 | }, 94 | }, 95 | }; 96 | -------------------------------------------------------------------------------- /src/schema/index.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | import userSchema from './user'; 4 | import messageSchema from './message'; 5 | 6 | const linkSchema = gql` 7 | scalar Date 8 | 9 | type Query { 10 | _: Boolean 11 | } 12 | 13 | type Mutation { 14 | _: Boolean 15 | } 16 | 17 | type Subscription { 18 | _: Boolean 19 | } 20 | `; 21 | 22 | export default [linkSchema, userSchema, messageSchema]; 23 | -------------------------------------------------------------------------------- /src/schema/message.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | extend type Query { 5 | messages(cursor: String, limit: Int): MessageConnection! 6 | message(id: ID!): Message! 7 | } 8 | 9 | extend type Mutation { 10 | createMessage(text: String!): Message! 11 | deleteMessage(id: ID!): Boolean! 12 | } 13 | 14 | type MessageConnection { 15 | edges: [Message!]! 16 | pageInfo: PageInfo! 17 | } 18 | 19 | type PageInfo { 20 | hasNextPage: Boolean! 21 | endCursor: String! 22 | } 23 | 24 | type Message { 25 | id: ID! 26 | text: String! 27 | createdAt: Date! 28 | user: User! 29 | } 30 | 31 | extend type Subscription { 32 | messageCreated: MessageCreated! 33 | } 34 | 35 | type MessageCreated { 36 | message: Message! 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /src/schema/user.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-server-express'; 2 | 3 | export default gql` 4 | extend type Query { 5 | users: [User!] 6 | user(id: ID!): User 7 | me: User 8 | } 9 | 10 | extend type Mutation { 11 | signUp( 12 | username: String! 13 | email: String! 14 | password: String! 15 | ): Token! 16 | 17 | signIn(login: String!, password: String!): Token! 18 | updateUser(username: String!): User! 19 | deleteUser(id: ID!): Boolean! 20 | } 21 | 22 | type Token { 23 | token: String! 24 | } 25 | 26 | type User { 27 | id: ID! 28 | username: String! 29 | email: String! 30 | role: String 31 | messages: [Message!] 32 | } 33 | `; 34 | -------------------------------------------------------------------------------- /src/subscription/index.js: -------------------------------------------------------------------------------- 1 | import { PubSub } from 'apollo-server'; 2 | 3 | import * as MESSAGE_EVENTS from './message'; 4 | 5 | export const EVENTS = { 6 | MESSAGE: MESSAGE_EVENTS, 7 | }; 8 | 9 | export default new PubSub(); 10 | -------------------------------------------------------------------------------- /src/subscription/message.js: -------------------------------------------------------------------------------- 1 | export const CREATED = 'CREATED'; 2 | -------------------------------------------------------------------------------- /src/tests/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const API_URL = 'http://localhost:8000/graphql'; 4 | 5 | export const signIn = async variables => 6 | await axios.post(API_URL, { 7 | query: ` 8 | mutation ($login: String!, $password: String!) { 9 | signIn(login: $login, password: $password) { 10 | token 11 | } 12 | } 13 | `, 14 | variables, 15 | }); 16 | 17 | export const me = async token => 18 | await axios.post( 19 | API_URL, 20 | { 21 | query: ` 22 | { 23 | me { 24 | id 25 | email 26 | username 27 | } 28 | } 29 | `, 30 | }, 31 | token 32 | ? { 33 | headers: { 34 | 'x-token': token, 35 | }, 36 | } 37 | : null, 38 | ); 39 | 40 | export const user = async variables => 41 | axios.post(API_URL, { 42 | query: ` 43 | query ($id: ID!) { 44 | user(id: $id) { 45 | id 46 | username 47 | email 48 | role 49 | } 50 | } 51 | `, 52 | variables, 53 | }); 54 | 55 | export const users = async () => 56 | axios.post(API_URL, { 57 | query: ` 58 | { 59 | users { 60 | id 61 | username 62 | email 63 | role 64 | } 65 | } 66 | `, 67 | }); 68 | 69 | export const signUp = async variables => 70 | axios.post(API_URL, { 71 | query: ` 72 | mutation( 73 | $username: String!, 74 | $email: String!, 75 | $password: String! 76 | ) { 77 | signUp( 78 | username: $username, 79 | email: $email, 80 | password: $password 81 | ) { 82 | token 83 | } 84 | } 85 | `, 86 | variables, 87 | }); 88 | 89 | export const updateUser = async (variables, token) => 90 | axios.post( 91 | API_URL, 92 | { 93 | query: ` 94 | mutation ($username: String!) { 95 | updateUser(username: $username) { 96 | username 97 | } 98 | } 99 | `, 100 | variables, 101 | }, 102 | token 103 | ? { 104 | headers: { 105 | 'x-token': token, 106 | }, 107 | } 108 | : null, 109 | ); 110 | 111 | export const deleteUser = async (variables, token) => 112 | axios.post( 113 | API_URL, 114 | { 115 | query: ` 116 | mutation ($id: ID!) { 117 | deleteUser(id: $id) 118 | } 119 | `, 120 | variables, 121 | }, 122 | token 123 | ? { 124 | headers: { 125 | 'x-token': token, 126 | }, 127 | } 128 | : null, 129 | ); 130 | -------------------------------------------------------------------------------- /src/tests/user.spec.js: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import * as userApi from './api'; 4 | 5 | describe('users', () => { 6 | describe('user(id: String!): User', () => { 7 | it('returns a user when user can be found', async () => { 8 | const expectedResult = { 9 | data: { 10 | user: { 11 | id: '1', 12 | username: 'rwieruch', 13 | email: 'hello@robin.com', 14 | role: 'ADMIN', 15 | }, 16 | }, 17 | }; 18 | 19 | const result = await userApi.user({ id: '1' }); 20 | 21 | expect(result.data).to.eql(expectedResult); 22 | }); 23 | 24 | it('returns null when user cannot be found', async () => { 25 | const expectedResult = { 26 | data: { 27 | user: null, 28 | }, 29 | }; 30 | 31 | const result = await userApi.user({ id: '42' }); 32 | 33 | expect(result.data).to.eql(expectedResult); 34 | }); 35 | }); 36 | 37 | describe('users: [User!]', () => { 38 | it('returns a list of users', async () => { 39 | const expectedResult = { 40 | data: { 41 | users: [ 42 | { 43 | id: '1', 44 | username: 'rwieruch', 45 | email: 'hello@robin.com', 46 | role: 'ADMIN', 47 | }, 48 | { 49 | id: '2', 50 | username: 'ddavids', 51 | email: 'hello@david.com', 52 | role: null, 53 | }, 54 | ], 55 | }, 56 | }; 57 | 58 | const result = await userApi.users(); 59 | 60 | expect(result.data).to.eql(expectedResult); 61 | }); 62 | }); 63 | 64 | describe('me: User', () => { 65 | it('returns null when no user is signed in', async () => { 66 | const expectedResult = { 67 | data: { 68 | me: null, 69 | }, 70 | }; 71 | 72 | const { data } = await userApi.me(); 73 | 74 | expect(data).to.eql(expectedResult); 75 | }); 76 | 77 | it('returns me when me is signed in', async () => { 78 | const expectedResult = { 79 | data: { 80 | me: { 81 | id: '1', 82 | username: 'rwieruch', 83 | email: 'hello@robin.com', 84 | }, 85 | }, 86 | }; 87 | 88 | const { 89 | data: { 90 | data: { 91 | signIn: { token }, 92 | }, 93 | }, 94 | } = await userApi.signIn({ 95 | login: 'rwieruch', 96 | password: 'rwieruch', 97 | }); 98 | 99 | const { data } = await userApi.me(token); 100 | 101 | expect(data).to.eql(expectedResult); 102 | }); 103 | }); 104 | 105 | describe('signUp, updateUser, deleteUser', () => { 106 | it('signs up a user, updates a user and deletes the user as admin', async () => { 107 | // sign up 108 | 109 | let { 110 | data: { 111 | data: { 112 | signUp: { token }, 113 | }, 114 | }, 115 | } = await userApi.signUp({ 116 | username: 'Mark', 117 | email: 'mark@gmule.com', 118 | password: 'asdasdasd', 119 | }); 120 | 121 | const { 122 | data: { 123 | data: { me }, 124 | }, 125 | } = await userApi.me(token); 126 | 127 | expect(me).to.eql({ 128 | id: '3', 129 | username: 'Mark', 130 | email: 'mark@gmule.com', 131 | }); 132 | 133 | // update as user 134 | 135 | const { 136 | data: { 137 | data: { updateUser }, 138 | }, 139 | } = await userApi.updateUser({ username: 'Mark' }, token); 140 | 141 | expect(updateUser.username).to.eql('Mark'); 142 | 143 | // delete as admin 144 | 145 | const { 146 | data: { 147 | data: { 148 | signIn: { token: adminToken }, 149 | }, 150 | }, 151 | } = await userApi.signIn({ 152 | login: 'rwieruch', 153 | password: 'rwieruch', 154 | }); 155 | 156 | const { 157 | data: { 158 | data: { deleteUser }, 159 | }, 160 | } = await userApi.deleteUser({ id: me.id }, adminToken); 161 | 162 | expect(deleteUser).to.eql(true); 163 | }); 164 | }); 165 | 166 | describe('deleteUser(id: String!): Boolean!', () => { 167 | it('returns an error because only admins can delete a user', async () => { 168 | const { 169 | data: { 170 | data: { 171 | signIn: { token }, 172 | }, 173 | }, 174 | } = await userApi.signIn({ 175 | login: 'ddavids', 176 | password: 'ddavids', 177 | }); 178 | 179 | const { 180 | data: { errors }, 181 | } = await userApi.deleteUser({ id: '1' }, token); 182 | 183 | expect(errors[0].message).to.eql('Not authorized as admin.'); 184 | }); 185 | }); 186 | 187 | describe('updateUser(username: String!): User!', () => { 188 | it('returns an error because only authenticated users can update a user', async () => { 189 | const { 190 | data: { errors }, 191 | } = await userApi.updateUser({ username: 'Mark' }); 192 | 193 | expect(errors[0].message).to.eql('Not authenticated as user.'); 194 | }); 195 | }); 196 | 197 | describe('signIn(login: String!, password: String!): Token!', () => { 198 | it('returns a token when a user signs in with username', async () => { 199 | const { 200 | data: { 201 | data: { 202 | signIn: { token }, 203 | }, 204 | }, 205 | } = await userApi.signIn({ 206 | login: 'ddavids', 207 | password: 'ddavids', 208 | }); 209 | 210 | expect(token).to.be.a('string'); 211 | }); 212 | 213 | it('returns a token when a user signs in with email', async () => { 214 | const { 215 | data: { 216 | data: { 217 | signIn: { token }, 218 | }, 219 | }, 220 | } = await userApi.signIn({ 221 | login: 'hello@david.com', 222 | password: 'ddavids', 223 | }); 224 | 225 | expect(token).to.be.a('string'); 226 | }); 227 | 228 | it('returns an error when a user provides a wrong password', async () => { 229 | const { 230 | data: { errors }, 231 | } = await userApi.signIn({ 232 | login: 'ddavids', 233 | password: 'dontknow', 234 | }); 235 | 236 | expect(errors[0].message).to.eql('Invalid password.'); 237 | }); 238 | }); 239 | 240 | it('returns an error when a user is not found', async () => { 241 | const { 242 | data: { errors }, 243 | } = await userApi.signIn({ 244 | login: 'dontknow', 245 | password: 'ddavids', 246 | }); 247 | 248 | expect(errors[0].message).to.eql( 249 | 'No user found with this login credentials.', 250 | ); 251 | }); 252 | }); 253 | --------------------------------------------------------------------------------