├── .eslintrc.json ├── .gitignore ├── .graphqlconfig.yml ├── README.md ├── client ├── apollo │ ├── mutations.js │ └── queries.js ├── components │ ├── Auth.jsx │ ├── Dashboard.jsx │ ├── Header.jsx │ ├── Landing.jsx │ └── ProtectedRoute.jsx ├── index.html └── index.jsx ├── database ├── datamodel.graphql └── prisma.yml ├── package.json ├── src ├── index.js ├── resolvers │ ├── Mutation.js │ └── Query.js └── schema.graphql └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "rules": { 4 | "no-param-reassign": ["error", { "props": false }] 5 | }, 6 | "globals": { 7 | "document": false 8 | }, 9 | "env": { 10 | "browser": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | src/**/*.css 12 | dist 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .env 26 | .cache 27 | 28 | prisma.graphql 29 | -------------------------------------------------------------------------------- /.graphqlconfig.yml: -------------------------------------------------------------------------------- 1 | projects: 2 | app: 3 | schemaPath: src/schema.graphql 4 | extensions: 5 | endpoints: 6 | default: http://localhost:4000 7 | database: 8 | schemaPath: src/generated/prisma.graphql 9 | extensions: 10 | prisma: database/prisma.yml 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fullstack Prisma & Apollo 2 | I'm building this to create a _dead simple_ 'fullstack' example of Graphql. Its demonstrating React, Apollo, graphql-yoga, and Prisma. 3 | 4 | Whenever I come to these kind of examples I always look to see how they handle user authentication, so all this app does is handles user signup and login. It persists user state using a session cookie, the goal is for this to serve a good base template to add whatever flavor or application on top of it. 5 | 6 | ## Prerequisites 7 | Install dependencies with `npm install` or `yarn install`. 8 | 9 | Be sure to have prisma and nodemon installed globally. 10 | ```shell 11 | npm install -g prisma graphql-cli nodemon 12 | ``` 13 | or 14 | ```shell 15 | yarn global prisma graphql-cli nodemon 16 | ``` 17 | 18 | ## Generating Prisma 19 | After logging into Prisma's CLI: 20 | ```shell 21 | prisma deploy database 22 | ``` 23 | 24 | Select Demo server which will give you an endpoint like `https://us1.prisma.sh/myusername-12345/my-project/dev`. 25 | 26 | Place a `.env` file in the database folder listing the variables in `database/prisma.yml`. For example: 27 | ``` 28 | APP_SECRET=mysecret123 29 | DB_URL=https://us1.prisma.sh/myusername-12345/my-project/dev 30 | ``` 31 | 32 | ## Running the dev server 33 | ```shell 34 | node src/index.js 35 | ``` 36 | 37 | Then go to `localhost:4000` to access the playground. 38 | 39 | ## Authorization 40 | I'm saving the user's id in a session using express-session. 41 | graphql-yoga 42 | https://github.com/prisma/graphql-yoga/tree/master/examples/authentication/express-session 43 | 44 | 45 | ## Why Sessions 46 | [Many](https://www.apollographql.com/docs/react/recipes/authentication.html) [examples](https://www.howtographql.com/graphql-js/6-authentication/) of using Apollo or graphql demonstrate saving a the user's JWT packet in respnse header or ... localstorage 😳. After reading quite a [few](https://www.rdegges.com/2018/please-stop-using-local-storage/) [posts](http://cryto.net/~joepie91/blog/2016/06/13/stop-using-jwt-for-sessions/) on the subject, I wanted to show a production ready, battle tested solution like sessions. This required a few caveats in the codebase such as query for checking if the user is logged in on page load. 47 | 48 | - For ApolloClient, had to switch from using apollo-boost because of [sessions not being persisted](https://github.com/apollographql/apollo-client/issues/4018#issuecomment-439654182). Manually installing `apollo-boost`'s constituent parts fixed the problem. 49 | -------------------------------------------------------------------------------- /client/apollo/mutations.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost'; 2 | 3 | export const SIGNUP = gql` 4 | mutation SignupMutation($name: String!, $email: String!, $password: String!) { 5 | signup(name: $name, email: $email, password: $password) { 6 | id 7 | email 8 | __typename 9 | } 10 | } 11 | `; 12 | 13 | export const LOGIN = gql` 14 | mutation LoginMutation($email: String!, $password: String!) { 15 | login(email: $email, password: $password) { 16 | id 17 | email 18 | __typename 19 | } 20 | } 21 | `; 22 | 23 | export const LOGOUT = gql` 24 | mutation LogoutMutation { 25 | logout { 26 | status 27 | __typename 28 | } 29 | } 30 | `; 31 | -------------------------------------------------------------------------------- /client/apollo/queries.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost'; 2 | 3 | export const GET_AUTH_STATUS = gql` 4 | { 5 | isLoggedIn { 6 | status 7 | __typename 8 | } 9 | } 10 | `; 11 | 12 | export const GET_USER = gql` 13 | { 14 | user { 15 | id 16 | name 17 | email 18 | __typename 19 | } 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /client/components/Auth.jsx: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Mutation } from 'react-apollo'; 4 | import { SIGNUP, LOGIN } from '../apollo/mutations'; 5 | import { GET_AUTH_STATUS } from '../apollo/queries'; 6 | 7 | class Auth extends PureComponent { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.state = { 12 | email: '', 13 | password: '', 14 | name: '', 15 | isSignup: props.type === 'SIGNUP', 16 | }; 17 | } 18 | 19 | render() { 20 | const { 21 | email, 22 | password, 23 | name, 24 | isSignup, 25 | } = this.state; 26 | 27 | return ( 28 |
29 |

{isSignup ? 'Sign Up' : 'Log In'}

30 | 31 | { 35 | cache.writeQuery({ 36 | query: GET_AUTH_STATUS, 37 | data: { 38 | isLoggedIn: { 39 | status: true, 40 | __typename: 'AuthStatus', 41 | }, 42 | }, 43 | }); 44 | }} 45 | > 46 | { 47 | mutation => ( 48 |
{ 51 | e.preventDefault(); 52 | mutation(); 53 | } 54 | } 55 | > 56 | { 57 | isSignup 58 | && ( 59 | 60 | 71 | 72 | ) 73 | } 74 | 75 | 86 | 87 | 98 | 99 | 100 |
101 | ) 102 | } 103 |
104 |
105 | ); 106 | } 107 | } 108 | 109 | Auth.propTypes = { 110 | type: PropTypes.string.isRequired, 111 | }; 112 | 113 | export default Auth; 114 | -------------------------------------------------------------------------------- /client/components/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Query } from 'react-apollo'; 3 | import { GET_USER } from '../apollo/queries'; 4 | 5 | const Dashboard = () => ( 6 | 9 | { 10 | ({ loading, error, data }) => { 11 | if (loading) return loading...; 12 | if (error) return error.; 13 | 14 | return ( 15 |
16 | Welcome to your dashboard 17 | {' '} 18 | {data.user.email} 19 |
20 | ); 21 | } 22 | } 23 |
24 | 25 | ); 26 | 27 | export default Dashboard; 28 | -------------------------------------------------------------------------------- /client/components/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { Query, Mutation } from 'react-apollo'; 4 | import { GET_AUTH_STATUS } from '../apollo/queries'; 5 | import { LOGOUT } from '../apollo/mutations'; 6 | 7 | const logoutUpdate = (cache, { data }) => { 8 | cache.writeQuery({ 9 | query: GET_AUTH_STATUS, 10 | data: { isLoggedIn: data.logout }, 11 | }); 12 | }; 13 | 14 | const Header = () => ( 15 | 18 | { 19 | ({ loading, error, data }) => ( 20 |
21 | HEADER 22 | {' - '} 23 | { 24 | (!loading && !data.isLoggedIn.status) 25 | ? ( 26 | 27 | sign up 28 | {' - '} 29 | log in 30 | 31 | ) 32 | : ( 33 | 37 | {logout => } 38 | 39 | ) 40 | } 41 | { 42 | error && error. 43 | } 44 |
45 | ) 46 | } 47 |
48 | ); 49 | 50 | export default Header; 51 | -------------------------------------------------------------------------------- /client/components/Landing.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Landing = () => ( 4 |
5 | Welcome to the home page! 6 |
7 | ); 8 | 9 | export default Landing; 10 | -------------------------------------------------------------------------------- /client/components/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Query } from 'react-apollo'; 3 | import { Redirect, Route } from 'react-router-dom'; 4 | import { GET_AUTH_STATUS } from '../apollo/queries'; 5 | 6 | const ProtectedRoute = ({ 7 | component: Component, 8 | unAuthenticatedOnly, 9 | authenticatedOnly, 10 | ...rest 11 | }) => ( 12 | ( 15 | 18 | { 19 | ({ loading, error, data }) => { 20 | if (loading) return loading...; 21 | if (error) return error.; 22 | 23 | if (authenticatedOnly && !data.isLoggedIn.status) return ; 24 | if (unAuthenticatedOnly && data.isLoggedIn.status) return ; 25 | 26 | return ; 27 | } 28 | } 29 | 30 | )} 31 | /> 32 | ); 33 | 34 | export default ProtectedRoute; 35 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Boilerplate 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /client/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { InMemoryCache } from 'apollo-cache-inmemory'; 4 | import { ApolloClient } from 'apollo-client'; 5 | import { HttpLink } from 'apollo-link-http'; 6 | import { ApolloProvider } from 'react-apollo'; 7 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 8 | import Auth from './components/Auth'; 9 | import Header from './components/Header'; 10 | import Landing from './components/Landing'; 11 | import Dashboard from './components/Dashboard'; 12 | import ProtectedRoute from './components/ProtectedRoute'; 13 | 14 | const client = new ApolloClient({ 15 | cache: new InMemoryCache(), 16 | link: new HttpLink({ 17 | credentials: 'include', 18 | uri: 'http://localhost:4000', 19 | }), 20 | }); 21 | 22 | const App = () => ( 23 | 24 | 25 |
26 | 27 | } /> 28 | } /> 29 | 30 | 31 | 32 | 33 | 34 | 35 | ); 36 | 37 | render(, document.getElementById('app')); 38 | -------------------------------------------------------------------------------- /database/datamodel.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: ID! @unique 3 | name: String! 4 | email: String! @unique 5 | password: String! 6 | } 7 | -------------------------------------------------------------------------------- /database/prisma.yml: -------------------------------------------------------------------------------- 1 | endpoint: ${env:DB_URL} 2 | datamodel: datamodel.graphql 3 | secret: ${env:APP_SECRET} 4 | 5 | hooks: 6 | post-deploy: 7 | - graphql get-schema --project database 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fullstack-prisma-apollo", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "dev": "NODE_ENV=development concurrently --kill-others-on-fail \"nodemon src/index.js\" \"parcel client/index.html\"", 8 | "build": "parcel build ./client/index.html/", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/captDaylight/fullstack-prisma-apollo.git" 14 | }, 15 | "author": "paul christophe", 16 | "license": "MIT", 17 | "bugs": { 18 | "url": "https://github.com/captDaylight/fullstack-prisma-apollo/issues" 19 | }, 20 | "homepage": "https://github.com/captDaylight/fullstack-prisma-apollo#readme", 21 | "dependencies": { 22 | "apollo-boost": "^0.1.20", 23 | "apollo-cache-inmemory": "^1.3.10", 24 | "apollo-client": "^2.4.6", 25 | "apollo-link-http": "^1.5.5", 26 | "bcryptjs": "^2.4.3", 27 | "dotenv": "^6.1.0", 28 | "express-session": "^1.15.6", 29 | "graphql": "^14.0.2", 30 | "graphql-yoga": "^1.16.7", 31 | "ms": "^2.1.1", 32 | "prisma-binding": "^2.1.6", 33 | "prop-types": "^15.6.2", 34 | "react": "^16.6.1", 35 | "react-apollo": "^2.2.4", 36 | "react-dom": "^16.6.1", 37 | "react-router-dom": "^4.3.1" 38 | }, 39 | "devDependencies": { 40 | "concurrently": "^4.0.1", 41 | "eslint": "^5.9.0", 42 | "eslint-config-airbnb": "^17.1.0", 43 | "eslint-plugin-import": "^2.14.0", 44 | "eslint-plugin-jsx-a11y": "^6.1.2", 45 | "eslint-plugin-react": "^7.11.1", 46 | "parcel": "^1.10.3" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const { GraphQLServer } = require('graphql-yoga'); 2 | const { Prisma } = require('prisma-binding'); 3 | const session = require('express-session'); 4 | const dotenv = require('dotenv'); 5 | const ms = require('ms'); 6 | const Mutation = require('./resolvers/Mutation'); 7 | const Query = require('./resolvers/Query'); 8 | 9 | const isProduction = process.env.NODE_ENV === 'production'; 10 | 11 | if (!isProduction) { 12 | dotenv.config(); 13 | } 14 | 15 | const resolvers = { 16 | Mutation, 17 | Query, 18 | }; 19 | 20 | const server = new GraphQLServer({ 21 | typeDefs: 'src/schema.graphql', 22 | resolvers, 23 | context: req => ({ 24 | ...req, 25 | db: new Prisma({ 26 | typeDefs: 'src/generated/prisma.graphql', 27 | endpoint: process.env.DB_URL, 28 | secret: process.env.APP_SECRET, 29 | }), 30 | }), 31 | }); 32 | 33 | server.express.use(session({ 34 | name: 'pid', 35 | secret: 'some-random-secret-here', 36 | resave: false, 37 | saveUninitialized: false, 38 | cookie: { 39 | httpOnly: true, 40 | secure: isProduction, 41 | maxAge: ms('1d'), 42 | }, 43 | })); 44 | 45 | server.start({ 46 | cors: { 47 | credentials: true, 48 | origin: 'http://localhost:1234', 49 | }, 50 | }, () => console.log('server is running on localhost:4000')); 51 | -------------------------------------------------------------------------------- /src/resolvers/Mutation.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | 3 | async function login(parent, args, context) { 4 | const user = await context.db.query.user({ where: { email: args.email } }); 5 | if (!user) throw new Error('wrong_credentials'); 6 | 7 | const isValidPass = await bcrypt.compare(args.password, user.password); 8 | if (!isValidPass) throw new Error('wrong_credentials'); 9 | 10 | context.request.session.userId = user.id; 11 | 12 | return user; 13 | } 14 | 15 | async function signup(parent, args, context, info) { 16 | const password = await bcrypt.hash(args.password, 10); 17 | const user = await context.db.mutation.createUser({ data: { ...args, password } }, info); 18 | 19 | context.request.session.userId = user.id; 20 | 21 | return user; 22 | } 23 | 24 | function logout(parent, args, context) { 25 | if (!context.request.session) throw new Error('auth_error'); 26 | 27 | context.request.session.destroy(); 28 | 29 | return { status: false }; 30 | } 31 | 32 | module.exports = { 33 | login, 34 | signup, 35 | logout, 36 | }; 37 | -------------------------------------------------------------------------------- /src/resolvers/Query.js: -------------------------------------------------------------------------------- 1 | const isLoggedIn = (parent, args, { request }) => ({ status: typeof request.session.userId !== 'undefined' }); 2 | 3 | async function user(parent, args, context, info) { 4 | const id = context.request.session.userId; 5 | 6 | if (typeof id === 'undefined') throw new Error('auth_error'); 7 | 8 | return context.db.query.user({ 9 | where: { id }, 10 | }, info); 11 | } 12 | 13 | module.exports = { 14 | isLoggedIn, 15 | user, 16 | }; 17 | -------------------------------------------------------------------------------- /src/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | isLoggedIn: AuthStatus! 3 | user: User 4 | } 5 | 6 | type Mutation { 7 | login(email: String!, password: String!): User 8 | signup(name: String!, email: String!, password: String!): User 9 | logout: AuthStatus! 10 | } 11 | 12 | type User { 13 | id: ID! 14 | name: String! 15 | email: String! 16 | } 17 | 18 | type AuthStatus { 19 | status: Boolean! 20 | } 21 | --------------------------------------------------------------------------------