├── .dockerignore
├── .env.example
├── .gitignore
├── Dockerfile
├── README.md
├── docker-compose.yml
├── docs
├── azure.md
├── heroku.md
├── index.md
└── zeit-now.md
├── frontend
├── package-lock.json
├── package.json
├── public
│ ├── favicon.ico
│ └── index.html
├── scripts
│ └── watch.js
└── src
│ ├── components
│ ├── App.js
│ ├── Footer.js
│ ├── Header.js
│ ├── HomePage.js
│ ├── Input.css
│ ├── Input.js
│ ├── ListFooter.js
│ ├── SignIn.css
│ ├── SignIn.js
│ ├── SignUp.css
│ ├── SignUp.js
│ └── TodoItem.js
│ ├── index.js
│ ├── logo.svg
│ ├── queries
│ └── todos.js
│ └── styles
│ └── index.css
├── go.mod
├── go.sum
├── heroku.yml
├── now.json
└── src
├── config
├── environment.go
├── jwt.go
└── pagination.go
├── db
└── pg.go
├── dev
└── watch-frontend.go
├── handler
├── lambda.go
└── now.go
├── main.go
├── resolvers
├── hello.go
├── hello_test.go
├── todo.go
└── user.go
├── runtime
├── handler.go
└── jwt.go
├── structs
├── auth.go
├── todo.go
└── user.go
├── test
└── assert.go
└── types
├── auth-response.go
├── mutation.go
├── query.go
├── schema.go
├── todo.go
└── user.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | docs
2 | frontend/node_modules
3 | frontend/build
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | POSTGRESS_ADDRESS="localhost:54320"
2 | POSTGRESS_DATABASE="postgres"
3 | POSTGRESS_USER="postgres"
4 | POSTGRESS_PASSWORD=""
5 | JWT_SECRET="your-jwt-secret"
6 | GO_SERVES_STATIC="true"
7 | ENVIRONMENT="development"
8 | PORT=8080
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | **/*.exe
3 | frontend/build/
4 | frontend/node_modules/
5 | functions/
6 | # Local Netlify folder
7 | .netlify
8 | main
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM jacob9706/go-node-docker
2 | WORKDIR /app
3 |
4 | # Install dependencies
5 | COPY go.mod go.sum ./
6 | RUN go mod download
7 |
8 | # Build the application and make executable
9 | COPY . .
10 | RUN go build -o ./main ./src/main.go
11 | RUN chmod +x ./main
12 |
13 | # Build the frontend
14 | RUN npm --prefix ./frontend install ./frontend
15 | RUN npm --prefix ./frontend run build
16 |
17 | EXPOSE 8080
18 |
19 | RUN wget -O /app/wait-for-it.sh https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh
20 | # Make db wait tool executable
21 | RUN chmod +x /app/wait-for-it.sh
22 | RUN apk add --no-cache bash
23 |
24 | CMD /app/wait-for-it.sh ${POSTGRESS_ADDRESS} -- ./main
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GO GraphQL Boilerplate
2 |
3 | A simple to use, deploys anywhere boilerplate to get you going in GO and GraphQL.
4 |
5 | Zeit Now Example: https://go-graphql-boilerplate.jacob-ebey.now.sh/
6 |
7 | Heroku Example: https://go-graphql-boilerplate.herokuapp.com/
8 |
9 |
10 | ## Getting started
11 |
12 | Get started by installing GO and Node(NPM), then cloneing this repo.
13 |
14 | Once cloned, install the GO dependencies:
15 |
16 | ```shell
17 | > go get ./...
18 | ```
19 |
20 | CD into the `frontend` directory and install the NPM dependencies:
21 |
22 | ```shell
23 | > npm install
24 | ```
25 |
26 |
27 | ## Dev mode
28 |
29 | Create A .env file in the root of the project, feel free to copy or rename the .env.example file. This is pre-configured for use with the docker-compose.yml file provied.
30 |
31 | Configure a combination of the following properties:
32 |
33 | ```
34 | DATABASE_URL="postgres://postgres@localhost:54320/postgres"
35 | POSTGRESS_ADDRESS="localhost:54320"
36 | JWT_SECRET="your-jwt-secret"
37 | GO_SERVES_STATIC="true"
38 | ENVIRONMENT="development"
39 | PORT="8080"
40 | ```
41 |
42 | OR
43 |
44 | ```
45 | POSTGRESS_ADDRESS="localhost:54320"
46 | POSTGRESS_DATABASE="postgres"
47 | POSTGRESS_USER="postgres"
48 | POSTGRESS_PASSWORD=""
49 | JWT_SECRET="your-jwt-secret"
50 | GO_SERVES_STATIC="true"
51 | ENVIRONMENT="development"
52 | PORT="8080"
53 | ```
54 |
55 | Both of the above configs are valid for the docker-compose.yml file provided. To spin up your DB, run:
56 |
57 | ```bash
58 | > docker-compose up -d
59 | ```
60 |
61 | Now we can start the GO backend. In if `ENVIRONMENT` is set to development, we will get auto rebuilding of the frontend.
62 |
63 |
64 | ## Production
65 |
66 | You have a few options for deploying the boilerplate out of the box:
67 |
68 | - [Azure](docs/azure.md)
69 | - [Heroku](docs/heroku.md)
70 | - [Zeit Now](docs/zeit-now.md)
71 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 | services:
3 | db:
4 | image: "postgres:11"
5 | container_name: "go-graphql-boilerplate-postgress"
6 | environment:
7 | POSTGRES_ENABLE_SSL: "true"
8 | ports:
9 | - "54320:5432"
10 | volumes:
11 | - my_dbdata:/var/lib/postgresql/data
12 | volumes:
13 | my_dbdata:
14 |
--------------------------------------------------------------------------------
/docs/azure.md:
--------------------------------------------------------------------------------
1 | # Deploying to azure
2 |
3 | ## Publishing to Docker
4 | ```bash
5 | > docker build . -t jacob9706/go-graphql-boilerplate
6 | ```
7 |
8 | ```bash
9 | > docker push jacob9706/go-graphql-boilerplate
10 | ```
11 |
12 | ## Create a resource group
13 |
14 | ```bash
15 | > az group create --name go-graphql-boilerplate-rg --location "West US"
16 | ```
17 |
18 | ## Create a service plan
19 |
20 | ```bash
21 | > az appservice plan create --name go-graphql-boilerplate-sp --resource-group go-graphql-boilerplate-rg --sku F1 --is-linux
22 | ```
23 |
24 | ## Create the web app
25 |
26 | ```bash
27 | > az webapp create --resource-group go-graphql-boilerplate-rg --plan go-graphql-boilerplate-sp --name go-graphql-boilerplate --deployment-container-image-name jacob9706/go-graphql-boilerplate
28 | ```
29 |
30 | # Configure envrionment variables
31 |
32 | ```bash
33 | > az webapp config appsettings set --name go-graphql-boilerplate --resource-group go-graphql-boilerplate-rg --settings DATABASE_URL='postgres://username:password@host:5432/dbname' POSTGRESS_ADDRESS='host:5432' GO_SERVES_STATIC='true' ENVIRONMENT='production' JWT_SECRET='your-jwt-secret'
34 | ```
35 |
36 | # View the status of the deployment
37 |
38 | ```bash
39 | > az webapp log tail --name go-graphql-boilerplate --resource-group go-graphql-boilerplate-rg
40 | ```
--------------------------------------------------------------------------------
/docs/heroku.md:
--------------------------------------------------------------------------------
1 | # Deploying to Heroku
2 |
3 | ## Create a heroku application (called go-graphql-boilerplate in this case)
4 |
5 | ```bash
6 | > heroku create -s container go-graphql-boilerplate
7 | ```
8 |
9 | ## Provision database
10 |
11 | ```bash
12 | > heroku addons:create heroku-postgresql:hobby-dev
13 | ```
14 |
15 | ## Configure environment variables
16 |
17 | ```bash
18 | > heroku config:set ENVIRONMENT="production"
19 | > heroku config:set IS_HEROKU=true
20 | > heroku config:set GO_SERVES_STATIC=true
21 | > heroku config:set JWT_SECRET="your-jwt-secret"
22 | ```
23 |
24 | Even though the above database provisioning command added the DATABASE_URL environment variable for us,
25 | the container startup process requies the host of the DB as another environment variable to verify that
26 | it is up. To get your DATABASE_URL to figure out the DB host execute:
27 |
28 | ```bash
29 | > heroku config:get DATABASE_URL
30 | ```
31 |
32 | This will give you a connection string similar to:
33 |
34 | ```bash
35 | > postgres://username:password@host:5432/dbname
36 | ```
37 |
38 | We will extract the `host:5432` portion directly after the `@` and set it as our POSTGRESS_ADDRESS
39 | environment variable:
40 |
41 | ```bash
42 | > heroku config:set POSTGRESS_ADDRESS="host:5432"
43 | ```
44 |
45 | ## Deploy the application
46 |
47 | ```bash
48 | > git push heroku master
49 | ```
50 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 | # GO GraphQL Boilerplate
2 |
3 | A simple to uses, deploys anywhere boilerplate to get you going in GO and GraphQL.
4 |
5 | Example: https://go-graphql-boilerplate.herokuapp.com/
6 |
7 |
8 | ## Getting started
9 |
10 | Get started by installing GO and Node(NPM), then cloneing this repo.
11 |
12 | Once cloned, install the GO dependencies:
13 |
14 | ```shell
15 | > go get ./...
16 | ```
17 |
18 | CD into the `frontend` directory and install the NPM dependencies:
19 |
20 | ```shell
21 | > npm install
22 | ```
23 |
24 |
25 | ## Dev mode
26 |
27 | Create A .env file in the root of the project, feel free to copy or rename the .env.example file. This is pre-configured for use with the docker-compose.yml file provied.
28 |
29 | Configure a combination of the following properties:
30 |
31 | ```
32 | DATABASE_URL="postgres://postgres@localhost:54320/postgres"
33 | POSTGRESS_ADDRESS="localhost:54320"
34 | JWT_SECRET="your-jwt-secret"
35 | GO_SERVES_STATIC="true"
36 | ENVIRONMENT="development"
37 | PORT="8080"
38 | ```
39 |
40 | OR
41 |
42 | ```
43 | POSTGRESS_ADDRESS="localhost:54320"
44 | POSTGRESS_DATABASE="postgres"
45 | POSTGRESS_USER="postgres"
46 | POSTGRESS_PASSWORD=""
47 | JWT_SECRET="your-jwt-secret"
48 | GO_SERVES_STATIC="true"
49 | ENVIRONMENT="development"
50 | PORT="8080"
51 | ```
52 |
53 | Both of the above configs are valid for the docker-compose.yml file provided. To spin up your DB, run:
54 |
55 | ```bash
56 | > docker-compose up -d
57 | ```
58 |
59 | Now we can start the GO backend. In if `ENVIRONMENT` is set to development, we will get auto rebuilding of the frontend.
60 |
61 |
62 | ## Production
63 |
64 | You have a few options for deploying the boilerplate out of the box:
65 |
66 | - [Azure](azure)
67 | - [Heroku](heroku)
68 | - [Zeit Now](zeit-now)
69 |
--------------------------------------------------------------------------------
/docs/zeit-now.md:
--------------------------------------------------------------------------------
1 | # Deploying to Zeit Now
2 |
3 | Zeit deployment is stupid simple, just do it:
4 |
5 | ```bash
6 | > now -n go-graphql-boilerplate -e DATABASE_URL="postgres://username:password@host:5432/dbname" -e ENVIRONMENT="production" -e JWT_SECRET="your-jwt-secret"
7 | ```
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "go-graphql-boilerplate-frontend",
3 | "dependencies": {
4 | "@apollo/react-hooks": "3.1.3",
5 | "apollo-boost": "0.4.4",
6 | "graphql": "14.5.8",
7 | "jwt-decode": "2.2.0",
8 | "react": "16.10.2",
9 | "react-dom": "16.10.2",
10 | "react-router-dom": "^5.1.2",
11 | "react-scripts": "2.1.8",
12 | "todomvc-app-css": "2.3.0"
13 | },
14 | "devDependencies": {
15 | "colors": "1.4.0"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "react-scripts test --env=jsdom",
21 | "eject": "react-scripts eject"
22 | },
23 | "browserslist": [
24 | ">0.2%",
25 | "not dead",
26 | "not ie <= 11",
27 | "not op_mini all"
28 | ],
29 | "proxy": "http://localhost:8080"
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jacob-ebey/go-graphql-boilerplate/887b89f9c7da2afcb3083361f525843efbc907b8/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 | React App
23 |
24 |
25 |
28 |
29 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/frontend/scripts/watch.js:
--------------------------------------------------------------------------------
1 | process.env.NODE_ENV = 'development';
2 |
3 | const fs = require('fs-extra');
4 | const paths = require('react-scripts/config/paths');
5 | const webpack = require('webpack');
6 | const importCwd = require('import-cwd');
7 | const config = importCwd('react-scripts/config/webpack.config')('production')
8 | const colors = require('colors');
9 |
10 | var entry = config.entry;
11 | var plugins = config.plugins;
12 |
13 | entry = entry.filter(fileName => !fileName.match(/webpackHotDevClient/));
14 | plugins = plugins.filter(plugin => !(plugin instanceof webpack.HotModuleReplacementPlugin));
15 |
16 | config.entry = entry;
17 | config.plugins = plugins;
18 |
19 | console.log("Building Frontend".bold.green)
20 |
21 | webpack(config).watch({}, (err, stats) => {
22 | if (err) {
23 | console.error(err);
24 | } else {
25 | copyPublicFolder();
26 | }
27 | console.log(stats.toString({
28 | chunks: false,
29 | colors: true
30 | }));
31 | });
32 |
33 | function copyPublicFolder() {
34 | fs.copySync(paths.appPublic, paths.appBuild, {
35 | dereference: true,
36 | filter: file => file !== paths.appHtml
37 | });
38 | }
--------------------------------------------------------------------------------
/frontend/src/components/App.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Switch, Route } from "react-router-dom";
3 | import decode from "jwt-decode";
4 | import { useApolloClient } from "@apollo/react-hooks"
5 |
6 | import Footer from "./Footer";
7 | import Header from "./Header";
8 | import HomePage from "./HomePage";
9 | import SignUp from "./SignUp";
10 |
11 | export default function App() {
12 | const initialLoggedIn = React.useMemo(() => {
13 | const auth = JSON.parse(
14 | localStorage.getItem("auth") || JSON.stringify(null)
15 | );
16 |
17 | if (auth && auth.refreshToken) {
18 | const decoded = decode(auth.refreshToken);
19 |
20 | if (decoded && Date.now() < decoded.exp * 1000) {
21 | return true;
22 | }
23 | }
24 |
25 | return false;
26 | }, []);
27 |
28 | const [loggedIn, setLoggedIn] = React.useState(initialLoggedIn);
29 |
30 | const client = useApolloClient();
31 |
32 | const onSignIn = React.useCallback(payload => {
33 | setLoggedIn(!!payload);
34 | localStorage.setItem("auth", JSON.stringify(payload));
35 | });
36 |
37 | const onSignOut = React.useCallback(() => {
38 | setLoggedIn(false);
39 | localStorage.removeItem("auth");
40 | client.resetStore();
41 | });
42 |
43 | const WrappedHome = props => (
44 |
45 | );
46 |
47 | const WrappedSignUp = props => ;
48 |
49 | return (
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Footer({ loggedIn, onSignOut }) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { gql } from "apollo-boost";
3 | import { useMutation } from "@apollo/react-hooks";
4 | import { Link } from "react-router-dom";
5 |
6 | import { TODOS_QUERY } from "../queries/todos";
7 |
8 | export default function Header({ loggedIn }) {
9 | const [value, setValue] = React.useState("");
10 |
11 | const [createTodo, { loading }] = useMutation(
12 | gql`
13 | mutation CreateTodo($text: String!) {
14 | createTodo(text: $text) {
15 | id
16 | text
17 | completed
18 | }
19 | }
20 | `,
21 | {
22 | variables: {
23 | text: value
24 | },
25 | update(
26 | cache,
27 | {
28 | data: { createTodo: createdTodo }
29 | }
30 | ) {
31 | const updateTodos = filter => {
32 | try {
33 | const { todos, todosLeft, todosTotal } = cache.readQuery({
34 | query: TODOS_QUERY,
35 | variables: { filter }
36 | });
37 |
38 | cache.writeQuery({
39 | query: TODOS_QUERY,
40 | variables: { filter },
41 | data: {
42 | todos: [createdTodo].concat(todos),
43 | todosLeft: todosLeft + 1,
44 | todosTotal: todosTotal + 1
45 | }
46 | });
47 | } catch (err) {}
48 | };
49 |
50 | updateTodos("ALL");
51 | updateTodos("ACTIVE");
52 |
53 | setValue("");
54 | }
55 | }
56 | );
57 |
58 | const onKeyDown = React.useCallback(
59 | e => {
60 | if (e.key === "Enter") {
61 | e.preventDefault();
62 |
63 | if (!loading) {
64 | createTodo();
65 | }
66 | }
67 | },
68 | [loading, createTodo, setValue]
69 | );
70 |
71 | const onChange = React.useCallback(event => setValue(event.target.value), [
72 | setValue
73 | ]);
74 |
75 | return (
76 |
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/frontend/src/components/HomePage.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useQuery } from "@apollo/react-hooks";
3 |
4 | import { TODOS_QUERY } from "../queries/todos";
5 | import SignIn from "./SignIn";
6 | import TodoItem from "./TodoItem";
7 | import ListFooter from "./ListFooter";
8 |
9 | export default function HomePage({
10 | loggedIn,
11 | onSignIn,
12 | onSignOut,
13 | match: {
14 | params: { filter }
15 | }
16 | }) {
17 | filter = (filter && filter.toUpperCase()) || "ALL";
18 |
19 | const { data, loading } = useQuery(TODOS_QUERY, {
20 | skip: !loggedIn,
21 | variables: { filter }
22 | });
23 |
24 | const hasCompleted = React.useMemo(
25 | () => data && data.todosTotal > data.todosLeft
26 | );
27 |
28 | return (
29 |
30 | {!loggedIn && }
31 |
32 | {loggedIn && data && data.todos && data.todosTotal > 0 && (
33 |
34 |
43 |
44 |
45 | )}
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/frontend/src/components/Input.css:
--------------------------------------------------------------------------------
1 | .Input {
2 | margin: 0 !important;
3 | width: 100% !important;
4 | border: none !important;
5 | }
--------------------------------------------------------------------------------
/frontend/src/components/Input.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import "./Input.css";
4 |
5 | function Input({ disabled, value, type, onChange, placeholder }) {
6 | return (
7 |
8 |
9 |
17 |
18 |
19 | );
20 | }
21 |
22 | export default Input;
23 |
--------------------------------------------------------------------------------
/frontend/src/components/ListFooter.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { gql } from "apollo-boost";
3 | import { useMutation, useApolloClient } from "@apollo/react-hooks";
4 | import { NavLink as Link } from "react-router-dom";
5 |
6 | export default function ListFooter({ todosLeft, hasCompleted }) {
7 | const client = useApolloClient();
8 |
9 | const [deleteCompletedTodos, { loading: deleting }] = useMutation(
10 | gql`
11 | mutation DeleteCompletedTodos {
12 | deleteCompletedTodos
13 | }
14 | `,
15 | {
16 | update(
17 | cache,
18 | {
19 | data: { deleteCompletedTodos }
20 | }
21 | ) {
22 | client.resetStore();
23 | }
24 | }
25 | );
26 |
27 | const deleteAll = React.useCallback(
28 | () => !deleting && deleteCompletedTodos(),
29 | [deleting, deleteCompletedTodos]
30 | );
31 |
32 | return (
33 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/src/components/SignIn.css:
--------------------------------------------------------------------------------
1 | .SignInError {
2 | padding: 15px;
3 | color: red;
4 | }
5 |
6 | .SignInButtons {
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | }
11 |
12 | .SignInButton {
13 | width: 50%;
14 | padding: 15px;
15 | cursor: pointer;
16 | text-align: center;
17 | text-decoration: none;
18 | color: black;
19 | }
--------------------------------------------------------------------------------
/frontend/src/components/SignIn.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { gql } from "apollo-boost";
3 | import { useMutation } from "@apollo/react-hooks";
4 | import { Link } from "react-router-dom";
5 |
6 | import Input from "./Input";
7 | import "./SignIn.css";
8 |
9 | export default function SignIn({ onSignIn }) {
10 | const [email, setEmail] = React.useState("");
11 | const [password, setPassword] = React.useState("");
12 |
13 | const createOnChange = React.useCallback(set => event =>
14 | set(event.target.value)
15 | );
16 |
17 | const [signIn, { loading, error }] = useMutation(gql`
18 | mutation SignIn($email: String!, $password: String!) {
19 | signIn(email: $email, password: $password) {
20 | token
21 | refreshToken
22 | user {
23 | email
24 | id
25 | }
26 | }
27 | }
28 | `);
29 |
30 | const onSubmit = React.useCallback(
31 | event => {
32 | event.preventDefault();
33 |
34 | if (loading) {
35 | return;
36 | }
37 |
38 | signIn({
39 | variables: { email, password }
40 | }).then(({ data }) => {
41 | if (data && data.signIn && data.signIn.token && data.signIn.user) {
42 | onSignIn && onSignIn(data.signIn);
43 | }
44 | });
45 | },
46 | [signIn, loading, email, password]
47 | );
48 |
49 | return (
50 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/frontend/src/components/SignUp.css:
--------------------------------------------------------------------------------
1 | .SignUpError {
2 | padding: 15px;
3 | color: red;
4 | }
5 |
6 | .SignUpButtons {
7 | display: flex;
8 | justify-content: center;
9 | align-items: center;
10 | }
11 |
12 | .SignUpButton {
13 | width: 50%;
14 | padding: 15px;
15 | cursor: pointer;
16 | }
--------------------------------------------------------------------------------
/frontend/src/components/SignUp.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { gql } from "apollo-boost";
3 | import { useMutation } from "@apollo/react-hooks";
4 | import { Link, Redirect } from "react-router-dom";
5 |
6 | import Input from "./Input";
7 | import "./SignUp.css";
8 |
9 | export default function SignUp({ onSignUp, loggedIn }) {
10 | const [email, setEmail] = React.useState("");
11 | const [password, setPassword] = React.useState("");
12 | const [confirmPassword, setConfirmPassword] = React.useState("");
13 |
14 | const createOnChange = React.useCallback(set => event =>
15 | set(event.target.value)
16 | );
17 |
18 | const [signUp, { loading, error }] = useMutation(
19 | gql`
20 | mutation SignUp(
21 | $email: String!
22 | $password: String!
23 | $confirmPassword: String!
24 | ) {
25 | signUp(
26 | email: $email
27 | password: $password
28 | confirmPassword: $confirmPassword
29 | ) {
30 | token
31 | refreshToken
32 | user {
33 | email
34 | id
35 | }
36 | }
37 | }
38 | `,
39 | {
40 | variables: { email, password, confirmPassword }
41 | }
42 | );
43 |
44 | const onSubmit = React.useCallback(
45 | event => {
46 | event.preventDefault();
47 |
48 | if (loading) {
49 | return;
50 | }
51 |
52 | signUp({
53 | variables: { email, password }
54 | }).then(({ data }) => {
55 | if (data && data.signUp && data.signUp.token && data.signUp.user) {
56 | onSignUp && onSignUp(data.signUp);
57 | }
58 | });
59 | },
60 | [signUp, loading, email, password, confirmPassword]
61 | );
62 |
63 | return loggedIn ? (
64 |
65 | ) : (
66 |
106 | );
107 | }
108 |
--------------------------------------------------------------------------------
/frontend/src/components/TodoItem.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { gql } from "apollo-boost";
3 | import { useMutation } from "@apollo/react-hooks";
4 |
5 | import { TODOS_QUERY } from "../queries/todos";
6 |
7 | function TodoItem({ completed, id, text, disabled }) {
8 | const [editing, setEditing] = React.useState(false);
9 | const [value, setValue] = React.useState(text);
10 |
11 | const [markTodo, { loading: markingTodo }] = useMutation(
12 | gql`
13 | mutation MarkTodo($id: Int!, $completed: Boolean!) {
14 | markTodo(id: $id, completed: $completed) {
15 | id
16 | text
17 | completed
18 | }
19 | }
20 | `,
21 | {
22 | variables: {
23 | id,
24 | completed: !completed
25 | },
26 | update(
27 | cache,
28 | {
29 | data: { __typename, markTodo: newTodo }
30 | }
31 | ) {
32 | const query = gql`
33 | {
34 | todosLeft
35 | todosTotal
36 | }
37 | `;
38 | const { todosLeft, todosTotal } = cache.readQuery({ query });
39 |
40 | let newLeft = newTodo.completed ? todosLeft - 1 : todosLeft + 1;
41 | newLeft = newLeft > 0 ? newLeft : 0;
42 |
43 | cache.writeData({
44 | query,
45 | data: {
46 | todosLeft: newLeft,
47 | todosTotal
48 | }
49 | });
50 |
51 | let activeTodos, completeTodos;
52 | let activeFromCache = false,
53 | completeFromCache = false;
54 | try {
55 | const { todos: tempActiveTodos } = cache.readQuery({
56 | query: TODOS_QUERY,
57 | variables: { filter: "ACTIVE" }
58 | });
59 | activeTodos = tempActiveTodos;
60 | activeFromCache = true;
61 | } catch (err) {
62 | activeTodos = [];
63 | }
64 |
65 | try {
66 | const { todos: tempCompleteTodos } = cache.readQuery({
67 | query: TODOS_QUERY,
68 | variables: { filter: "COMPLETE" }
69 | });
70 | completeTodos = tempCompleteTodos;
71 | completeFromCache = true;
72 | } catch (err) {
73 | completeTodos = [];
74 | }
75 |
76 | if (newTodo.completed) {
77 | if (activeFromCache) {
78 | cache.writeQuery({
79 | query: TODOS_QUERY,
80 | variables: { filter: "ACTIVE" },
81 | data: {
82 | todos: activeTodos.filter(todo => todo.id !== newTodo.id),
83 | todosLeft: newLeft,
84 | todosTotal
85 | }
86 | });
87 | }
88 | if (completeFromCache) {
89 | cache.writeQuery({
90 | query: TODOS_QUERY,
91 | variables: { filter: "COMPLETE" },
92 | data: {
93 | todos: [newTodo].concat(completeTodos),
94 | todosLeft: newLeft,
95 | todosTotal
96 | }
97 | });
98 | }
99 | } else {
100 | if (activeFromCache) {
101 | cache.writeQuery({
102 | query: TODOS_QUERY,
103 | variables: { filter: "ACTIVE" },
104 | data: {
105 | todos: [newTodo].concat(activeTodos),
106 | todosLeft: newLeft,
107 | todosTotal
108 | }
109 | });
110 | }
111 | if (completeFromCache) {
112 | cache.writeQuery({
113 | query: TODOS_QUERY,
114 | variables: { filter: "COMPLETE" },
115 | data: {
116 | todos: completeTodos.filter(todo => todo.id !== newTodo.id),
117 | todosLeft: newLeft,
118 | todosTotal
119 | }
120 | });
121 | }
122 | }
123 | }
124 | }
125 | );
126 |
127 | const [editTodo, { loading: savingTodo }] = useMutation(
128 | gql`
129 | mutation EditTodo($id: Int!, $text: String!) {
130 | editTodo(id: $id, text: $text) {
131 | id
132 | text
133 | }
134 | }
135 | `,
136 | {
137 | variables: {
138 | id,
139 | text: value
140 | },
141 | optimisticResponse: {
142 | __typename: "Mutation",
143 | editTodo: {
144 | __typename: "Todo",
145 | id,
146 | text: value
147 | }
148 | }
149 | }
150 | );
151 |
152 | const [deleteTodo, { loading: deletingTodo }] = useMutation(
153 | gql`
154 | mutation DeleteTodo($id: Int!) {
155 | deleteTodo(id: $id) {
156 | id
157 | }
158 | }
159 | `,
160 | {
161 | variables: {
162 | id
163 | },
164 | update(
165 | cache,
166 | {
167 | data: { deleteCompletedTodos }
168 | }
169 | ) {
170 | const update = filter => {
171 | try {
172 | const { todos, ...rest } = cache.readQuery({
173 | query: TODOS_QUERY,
174 | variables: { filter }
175 | });
176 |
177 | cache.writeQuery({
178 | query: TODOS_QUERY,
179 | variables: { filter },
180 | data: {
181 | ...rest,
182 | todos: todos.filter(todo => todo.id !== id)
183 | }
184 | });
185 | } catch (err) {}
186 | };
187 |
188 | update("ALL");
189 | update("ACTIVE");
190 | update("COMPLETE");
191 | }
192 | }
193 | );
194 |
195 | const loading = React.useMemo(
196 | () => markingTodo || savingTodo || deletingTodo,
197 | [markingTodo, savingTodo, deletingTodo]
198 | );
199 |
200 | const onChange = React.useCallback(event => setValue(event.target.value), [
201 | setValue
202 | ]);
203 | const onFocus = React.useCallback(() => !completed && setEditing(true), [
204 | completed,
205 | setEditing
206 | ]);
207 |
208 | const onBlur = React.useCallback(() => {
209 | setEditing(false);
210 | editTodo();
211 | }, [setEditing]);
212 |
213 | const onCompletedChanged = React.useCallback(() => !loading && markTodo(), [
214 | markTodo,
215 | loading
216 | ]);
217 |
218 | const onDelete = React.useCallback(() => !loading && deleteTodo(), [
219 | loading,
220 | deleteTodo
221 | ]);
222 |
223 | return (
224 |
229 | {!disabled && editing ? (
230 |
239 | ) : (
240 |
241 |
247 |
248 |
253 |
254 | )}
255 |
256 | );
257 | }
258 |
259 | export default TodoItem;
260 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import { ApolloProvider } from "@apollo/react-hooks";
4 | import ApolloClient, { gql } from "apollo-boost";
5 | import { HashRouter as Router } from "react-router-dom";
6 | import "todomvc-app-css/index.css";
7 | import decode from "jwt-decode";
8 |
9 | import "../src/styles/index.css";
10 | import App from "../src/components/App";
11 |
12 | const refreshClient = new ApolloClient({
13 | uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || "/graphql",
14 | request: async operation => {
15 | const auth = JSON.parse(
16 | localStorage.getItem("auth") || JSON.stringify(null)
17 | );
18 |
19 | const Authorization =
20 | auth && auth.refreshToken ? `Bearer ${auth.refreshToken}` : undefined;
21 |
22 | operation.setContext({
23 | headers: {
24 | Authorization
25 | }
26 | });
27 | }
28 | });
29 |
30 | const client = new ApolloClient({
31 | uri: process.env.REACT_APP_GRAPHQL_ENDPOINT || "/graphql",
32 | request: async operation => {
33 | const auth = JSON.parse(
34 | localStorage.getItem("auth") || JSON.stringify(null)
35 | );
36 |
37 | let token = auth && auth.token;
38 |
39 | if (token) {
40 | const decoded = decode(token);
41 |
42 | if (decoded && Date.now() >= decoded.exp * 1000) {
43 | const decodedRefreshToken = decode(auth.refreshToken);
44 |
45 | if (decodedRefreshToken && Date.now() < decodedRefreshToken.exp * 1000) {
46 | const { data } = await refreshClient.mutate({
47 | mutation: gql`
48 | mutation RefreshToken {
49 | refreshToken {
50 | token
51 | refreshToken
52 | user {
53 | email
54 | id
55 | }
56 | }
57 | }
58 | `
59 | });
60 |
61 | if (data && data.refreshToken) {
62 | localStorage.setItem("auth", JSON.stringify(data.refreshToken));
63 | token = data.refreshToken.token;
64 | } else {
65 | localStorage.removeItem("auth");
66 | token = undefined
67 | }
68 | } else {
69 | localStorage.removeItem("auth");
70 | token = undefined
71 | }
72 | }
73 | }
74 |
75 | const Authorization = token ? `Bearer ${token}` : undefined;
76 |
77 | operation.setContext({
78 | headers: {
79 | Authorization
80 | }
81 | });
82 | }
83 | });
84 |
85 | //Apollo Client
86 | ReactDOM.render(
87 |
88 |
89 |
90 |
91 | ,
92 | document.getElementById("root")
93 | );
94 |
--------------------------------------------------------------------------------
/frontend/src/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/frontend/src/queries/todos.js:
--------------------------------------------------------------------------------
1 | import { gql } from "apollo-boost";
2 |
3 | export const TODOS_QUERY = gql`
4 | query Todos($skip: Int, $limit: Int, $filter: TodoFilter) {
5 | todos(skip: $skip, limit: $limit, filter: $filter)
6 | @connection(key: "todos", filter: ["filter"]) {
7 | id
8 | text
9 | completed
10 | }
11 | todosLeft
12 | todosTotal
13 | }
14 | `;
15 |
--------------------------------------------------------------------------------
/frontend/src/styles/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0 auto;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jacob-ebey/go-graphql-boilerplate
2 |
3 | go 1.13
4 |
5 | require (
6 | github.com/aws/aws-lambda-go v1.13.2
7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible
8 | github.com/go-pg/pg v8.0.6+incompatible
9 | github.com/gorilla/mux v1.7.3
10 | github.com/graphql-go/graphql v0.7.8
11 | github.com/graphql-go/handler v0.2.3
12 | github.com/jinzhu/inflection v1.0.0 // indirect
13 | github.com/joho/godotenv v1.3.0
14 | github.com/onsi/ginkgo v1.10.2 // indirect
15 | github.com/onsi/gomega v1.7.0 // indirect
16 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550
17 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 // indirect
18 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 // indirect
19 | mellium.im/sasl v0.2.1 // indirect
20 | )
21 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
2 | github.com/aws/aws-lambda-go v1.13.2 h1:8lYuRVn6rESoUNZXdbCmtGB4bBk4vcVYojiHjE4mMrM=
3 | github.com/aws/aws-lambda-go v1.13.2/go.mod h1:4UKl9IzQMoD+QF79YdCuzCwp8VbmG4VAQwij/eHl5CU=
4 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
8 | github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
9 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
10 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
11 | github.com/go-pg/pg v8.0.6+incompatible h1:Hi7yUJ2zwmHFq1Mar5XqhCe3NJ7j9r+BaiNmd+vqf+A=
12 | github.com/go-pg/pg v8.0.6+incompatible/go.mod h1:a2oXow+aFOrvwcKs3eIA0lNFmMilrxK2sOkB5NWe0vA=
13 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
14 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
15 | github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
16 | github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
17 | github.com/graphql-go/graphql v0.7.8 h1:769CR/2JNAhLG9+aa8pfLkKdR0H+r5lsQqling5WwpU=
18 | github.com/graphql-go/graphql v0.7.8/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI=
19 | github.com/graphql-go/handler v0.2.3 h1:CANh8WPnl5M9uA25c2GBhPqJhE53Fg0Iue/fRNla71E=
20 | github.com/graphql-go/handler v0.2.3/go.mod h1:leLF6RpV5uZMN1CdImAxuiayrYYhOk33bZciaUGaXeU=
21 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
22 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
23 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
24 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
25 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
26 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
27 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
28 | github.com/onsi/ginkgo v1.10.2 h1:uqH7bpe+ERSiDa34FDOF7RikN6RzXgduUF8yarlZp94=
29 | github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
30 | github.com/onsi/gomega v1.7.0 h1:XPnZz8VVBHjVsy1vzJmRwIcSwiUO+JFfrv/xGiigmME=
31 | github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
32 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
33 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
34 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
35 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
37 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
38 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
39 | github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
40 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b h1:2b9XGzhjiYsYPnKXoEfL7klWZQIt8IfyRCz62gCqqlQ=
41 | golang.org/x/crypto v0.0.0-20180910181607-0e37d006457b/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
42 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
43 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8=
44 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
45 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
46 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
47 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI=
48 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
49 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
50 | golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU=
51 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
52 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
53 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
54 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI=
55 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
56 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
57 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
58 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
60 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
61 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
62 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
63 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
64 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
65 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
66 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
67 | mellium.im/sasl v0.2.1 h1:nspKSRg7/SyO0cRGY71OkfHab8tf9kCts6a6oTDut0w=
68 | mellium.im/sasl v0.2.1/go.mod h1:ROaEDLQNuf9vjKqE1SrAfnsobm2YKXT1gnN1uDp1PjQ=
69 |
--------------------------------------------------------------------------------
/heroku.yml:
--------------------------------------------------------------------------------
1 | build:
2 | docker:
3 | web: Dockerfile
4 |
--------------------------------------------------------------------------------
/now.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "env": {
4 | "DATABASE_URL": "@database-url",
5 | "POSTGRESS_ADDRESS": "@postgress-address",
6 | "POSTGRESS_DATABASE": "@postgress-database",
7 | "POSTGRESS_USER": "@postgress-user",
8 | "POSTGRESS_PASSWORD": "@postgress-password",
9 | "JWT_SECRET": "@jwt-secret"
10 | },
11 | "builds": [
12 | {
13 | "use": "@now/static-build",
14 | "src": "frontend/package.json",
15 | "config": {
16 | "distDir": "build"
17 | }
18 | },
19 | {
20 | "use": "@now/go",
21 | "src": "src/handler/now.go"
22 | }
23 | ],
24 | "routes": [
25 | {
26 | "src": "^/graphql",
27 | "dest": "src/handler/now.go"
28 | },
29 | {
30 | "src": "^/favicon.ico",
31 | "dest": "frontend/favicon.ico"
32 | },
33 | {
34 | "src": "^/static/(.*)",
35 | "dest": "frontend/static/$1"
36 | },
37 | {
38 | "src": ".*",
39 | "dest": "frontend"
40 | }
41 | ]
42 | }
--------------------------------------------------------------------------------
/src/config/environment.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "crypto/tls"
5 | "fmt"
6 | "os"
7 | "strings"
8 |
9 | "github.com/go-pg/pg"
10 | )
11 |
12 | func IsDevelopment() bool { return os.Getenv("ENVIRONMENT") == "development" }
13 |
14 | func ShouldServeStaticFiles() bool { return os.Getenv("GO_SERVES_STATIC") == "true" }
15 |
16 | func GetAddress() string {
17 | addr := ":" + os.Getenv("PORT")
18 | if IsDevelopment() && !IsDocker() {
19 | addr = "localhost" + addr
20 | }
21 |
22 | return addr
23 | }
24 |
25 | func GetJwtSecret() []byte {
26 | secret := os.Getenv("JWT_SECRET")
27 |
28 | if secret == "" {
29 | panic(fmt.Errorf("No JWT_SECRET environment variable provided."))
30 | }
31 |
32 | return []byte(secret)
33 | }
34 |
35 | func IsDocker() bool { return os.Getenv("IS_DOCKER") == "true" }
36 |
37 | func IsHeroku() bool { return os.Getenv("IS_HEROKU") == "true" }
38 |
39 | func GetPgOptions() *pg.Options {
40 | databaseURL := os.Getenv("DATABASE_URL")
41 |
42 | var options *pg.Options
43 | if strings.Contains(databaseURL, "@") {
44 | parsed, _ := pg.ParseURL(databaseURL)
45 | options = parsed
46 | } else {
47 | options = &pg.Options{
48 | Addr: os.Getenv("POSTGRESS_ADDRESS"),
49 | Database: os.Getenv("POSTGRESS_DATABASE"),
50 | User: os.Getenv("POSTGRESS_USER"),
51 | Password: os.Getenv("POSTGRESS_PASSWORD"),
52 | }
53 | }
54 |
55 | if IsHeroku() {
56 | if options.TLSConfig == nil {
57 | options.TLSConfig = &tls.Config{}
58 | }
59 | options.TLSConfig.InsecureSkipVerify = true
60 | }
61 | return options
62 | }
63 |
--------------------------------------------------------------------------------
/src/config/jwt.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "strings"
4 |
5 | const JwtHeader = "Authorization"
6 | const JwtType = "Bearer"
7 |
8 | func GetJwtIssuer() string {
9 | return "go-graphql-boilerplate"
10 | }
11 |
12 | func GetToken(str string) string {
13 | split := strings.SplitN(str, " ", 2)
14 |
15 | if len(split) == 2 && split[0] == JwtType {
16 | return split[1]
17 | }
18 |
19 | return ""
20 | }
21 |
--------------------------------------------------------------------------------
/src/config/pagination.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | const LimitDefault = 20
4 |
--------------------------------------------------------------------------------
/src/db/pg.go:
--------------------------------------------------------------------------------
1 | package db
2 |
3 | import (
4 | "github.com/go-pg/pg"
5 | "github.com/go-pg/pg/orm"
6 |
7 | "github.com/jacob-ebey/go-graphql-boilerplate/src/structs"
8 | )
9 |
10 | func Connect(options *pg.Options, dev bool) (*pg.DB, error) {
11 | db := pg.Connect(options)
12 |
13 | types := []interface{}{
14 | (*structs.Todo)(nil),
15 | (*structs.User)(nil),
16 | }
17 |
18 | for _, model := range types {
19 | err := db.CreateTable(model, &orm.CreateTableOptions{
20 | Temp: dev,
21 | IfNotExists: true,
22 | })
23 |
24 | if err != nil {
25 | return nil, err
26 | }
27 | }
28 |
29 | return db, nil
30 | }
31 |
--------------------------------------------------------------------------------
/src/dev/watch-frontend.go:
--------------------------------------------------------------------------------
1 | package dev
2 |
3 | import (
4 | "log"
5 | "os"
6 | "os/exec"
7 | "path"
8 | )
9 |
10 | func WatchFrontend() {
11 | command := exec.Command("node", path.Clean("./scripts/watch.js"))
12 | command.Dir = path.Clean("./frontend")
13 | command.Stdout = os.Stdout
14 | command.Stderr = os.Stderr
15 |
16 | if err := command.Run(); err != nil {
17 | log.Fatal(err)
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/handler/lambda.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 |
8 | "github.com/aws/aws-lambda-go/events"
9 | "github.com/graphql-go/graphql"
10 |
11 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
12 | "github.com/jacob-ebey/go-graphql-boilerplate/src/runtime"
13 | )
14 |
15 | func NewLambdaHandler(executor runtime.Executor) func(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
16 | var params struct {
17 | Query string `json:"query"`
18 | OperationName string `json:"operationName"`
19 | Variables map[string]interface{} `json:"variables"`
20 | }
21 |
22 | return func(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
23 | if err := json.Unmarshal([]byte(request.Body), ¶ms); err != nil {
24 | return events.APIGatewayProxyResponse{
25 | Body: `{"errors": [{ message: "Invalid request body." }]}`,
26 | StatusCode: 400,
27 | }, nil
28 | }
29 |
30 | authHeader := request.Headers[config.JwtHeader]
31 | user := runtime.GetUserFromToken(authHeader)
32 | ctx := context.WithValue(executor.Context, "user", user)
33 |
34 | result := graphql.Do(graphql.Params{
35 | Schema: *executor.Handler.Schema,
36 | Context: ctx,
37 | OperationName: params.OperationName,
38 | RequestString: params.Query,
39 | VariableValues: params.Variables,
40 | })
41 |
42 | response, err := json.Marshal(result)
43 | if err != nil {
44 | fmt.Println("Could not encode response body")
45 | }
46 |
47 | return events.APIGatewayProxyResponse{
48 | Body: string(response),
49 | StatusCode: 200,
50 | }, nil
51 | }
52 | }
53 |
54 | // func main() {
55 | // executor := runtime.NewExecutor(config.GetPgOptions(), false)
56 | // defer executor.Close()
57 |
58 | // lambda.Start(NewLambdaHandler(executor))
59 | // }
60 |
--------------------------------------------------------------------------------
/src/handler/now.go:
--------------------------------------------------------------------------------
1 | package handler
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
7 | "github.com/jacob-ebey/go-graphql-boilerplate/src/runtime"
8 | )
9 |
10 | var handler, _ = runtime.NewHandler(config.GetPgOptions(), false)
11 |
12 | func Handler(w http.ResponseWriter, r *http.Request) {
13 | handler(w, r)
14 | }
15 |
--------------------------------------------------------------------------------
/src/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "path"
7 | "time"
8 |
9 | "github.com/gorilla/mux"
10 | "github.com/joho/godotenv"
11 |
12 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
13 | "github.com/jacob-ebey/go-graphql-boilerplate/src/dev"
14 | "github.com/jacob-ebey/go-graphql-boilerplate/src/runtime"
15 | )
16 |
17 | func main() {
18 | godotenv.Load(".env")
19 |
20 | options := config.GetPgOptions()
21 |
22 | handler, close := runtime.NewHandler(options, config.IsDevelopment())
23 | defer close()
24 |
25 | router := mux.NewRouter()
26 | router.HandleFunc("/graphql", handler)
27 |
28 | if config.ShouldServeStaticFiles() {
29 | fileServer := http.FileServer(http.Dir(path.Clean("./frontend/build")))
30 | router.PathPrefix("/static").Handler(fileServer)
31 | router.Handle("/favicon.ico", fileServer)
32 |
33 | router.PathPrefix("/").HandlerFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 | http.ServeFile(w, r, path.Clean("./frontend/build/index.html"))
35 | }))
36 | }
37 |
38 | if config.IsDevelopment() && config.ShouldServeStaticFiles() {
39 | go dev.WatchFrontend()
40 | }
41 |
42 | server := &http.Server{
43 | Handler: router,
44 | Addr: config.GetAddress(),
45 | WriteTimeout: 15 * time.Second,
46 | ReadTimeout: 15 * time.Second,
47 | }
48 |
49 | if err := server.ListenAndServe(); err != nil {
50 | fmt.Println(err)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/resolvers/hello.go:
--------------------------------------------------------------------------------
1 | package resolvers
2 |
3 | import "github.com/graphql-go/graphql"
4 |
5 | func SayHello(params graphql.ResolveParams) (interface{}, error) {
6 | name := params.Args["name"]
7 |
8 | if name == nil {
9 | name = "World"
10 | }
11 |
12 | return "Hello, " + name.(string) + "!", nil
13 | }
14 |
--------------------------------------------------------------------------------
/src/resolvers/hello_test.go:
--------------------------------------------------------------------------------
1 | package resolvers
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/graphql-go/graphql"
7 | "github.com/jacob-ebey/go-graphql-boilerplate/src/test"
8 | )
9 |
10 | func TestSayHello(t *testing.T) {
11 | res, err := SayHello(graphql.ResolveParams{
12 | Args: map[string]interface{}{
13 | "name": "",
14 | },
15 | })
16 |
17 | test.AssertEqual(t, err, nil)
18 | test.AssertEqual(t, res, "Hello, World!")
19 | }
20 |
21 | func TestSayHelloToName(t *testing.T) {
22 | args := map[string]interface{}{
23 | "name": "test",
24 | }
25 |
26 | res, err := SayHello(graphql.ResolveParams{
27 | Args: args,
28 | })
29 |
30 | test.AssertEqual(t, err, nil)
31 | test.AssertEqual(t, res, "Hello, test!")
32 | }
33 |
--------------------------------------------------------------------------------
/src/resolvers/todo.go:
--------------------------------------------------------------------------------
1 | package resolvers
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/go-pg/pg"
8 | "github.com/graphql-go/graphql"
9 |
10 | "github.com/jacob-ebey/go-graphql-boilerplate/src/structs"
11 | )
12 |
13 | func GetTodos(params graphql.ResolveParams) (interface{}, error) {
14 | database := params.Context.Value("database").(*pg.DB)
15 | user := params.Context.Value("user").(*structs.Claims)
16 |
17 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
18 | return nil, fmt.Errorf("Not authenticated.")
19 | }
20 |
21 | skip, _ := params.Args["skip"].(int)
22 | limit, _ := params.Args["limit"].(int)
23 | filter, _ := params.Args["filter"].(string)
24 |
25 | var todos []structs.Todo
26 | query := database.
27 | Model(&todos).
28 | Where("user_id = ?", user.ID)
29 |
30 | switch filter {
31 | case "active":
32 | query = query.Where("completed IS NOT TRUE")
33 | break
34 | case "complete":
35 | query = query.Where("completed IS TRUE")
36 | }
37 |
38 | if err := query.
39 | OrderExpr("id DESC").
40 | Offset(skip).
41 | Limit(limit).
42 | Select(); err != nil {
43 | return nil, err
44 | }
45 |
46 | return todos, nil
47 | }
48 |
49 | func TodosLeft(params graphql.ResolveParams) (interface{}, error) {
50 | database := params.Context.Value("database").(*pg.DB)
51 | user := params.Context.Value("user").(*structs.Claims)
52 |
53 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
54 | return nil, fmt.Errorf("Not authenticated.")
55 | }
56 |
57 | count, err := database.
58 | Model(&structs.Todo{}).
59 | Where("user_id = ?", user.ID).
60 | Where("completed IS NOT TRUE").
61 | Count()
62 |
63 | if err != nil {
64 | return nil, err
65 | }
66 |
67 | return count, nil
68 | }
69 |
70 | func TodosTotal(params graphql.ResolveParams) (interface{}, error) {
71 | database := params.Context.Value("database").(*pg.DB)
72 | user := params.Context.Value("user").(*structs.Claims)
73 |
74 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
75 | return nil, fmt.Errorf("Not authenticated.")
76 | }
77 |
78 | count, err := database.
79 | Model(&structs.Todo{}).
80 | Where("user_id = ?", user.ID).
81 | Count()
82 |
83 | if err != nil {
84 | return nil, err
85 | }
86 |
87 | return count, nil
88 | }
89 |
90 | func CreateTodo(params graphql.ResolveParams) (interface{}, error) {
91 | database := params.Context.Value("database").(*pg.DB)
92 | user := params.Context.Value("user").(*structs.Claims)
93 |
94 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
95 | return nil, fmt.Errorf("Not authenticated.")
96 | }
97 |
98 | text, _ := params.Args["text"].(string)
99 |
100 | newTodo := structs.Todo{
101 | Text: text,
102 | Completed: false,
103 | UserID: user.ID,
104 | }
105 |
106 | if err := database.Insert(&newTodo); err != nil {
107 | return nil, err
108 | }
109 |
110 | return newTodo, nil
111 | }
112 |
113 | func MarkTodoCompleted(params graphql.ResolveParams) (interface{}, error) {
114 | database := params.Context.Value("database").(*pg.DB)
115 | user := params.Context.Value("user").(*structs.Claims)
116 |
117 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
118 | return nil, fmt.Errorf("Not authenticated.")
119 | }
120 |
121 | id, _ := params.Args["id"].(int)
122 | completed, _ := params.Args["completed"].(bool)
123 |
124 | toUpdate := structs.Todo{
125 | ID: id,
126 | }
127 |
128 | if err := database.Select(&toUpdate); err != nil {
129 | return nil, err
130 | }
131 |
132 | if toUpdate.UserID != user.ID {
133 | return nil, fmt.Errorf("Not authrorized.")
134 | }
135 |
136 | toUpdate.Completed = completed
137 |
138 | if err := database.Update(&toUpdate); err != nil {
139 | return nil, err
140 | }
141 |
142 | return toUpdate, nil
143 | }
144 |
145 | func EditTodo(params graphql.ResolveParams) (interface{}, error) {
146 | database := params.Context.Value("database").(*pg.DB)
147 | user := params.Context.Value("user").(*structs.Claims)
148 |
149 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
150 | return nil, fmt.Errorf("Not authenticated.")
151 | }
152 |
153 | id, _ := params.Args["id"].(int)
154 | text, _ := params.Args["text"].(string)
155 |
156 | toUpdate := structs.Todo{
157 | ID: id,
158 | }
159 |
160 | if err := database.Select(&toUpdate); err != nil {
161 | return nil, err
162 | }
163 |
164 | if toUpdate.UserID != user.ID {
165 | return nil, fmt.Errorf("Not authrorized.")
166 | }
167 |
168 | toUpdate.Text = text
169 |
170 | if err := database.Update(&toUpdate); err != nil {
171 | return nil, err
172 | }
173 |
174 | return toUpdate, nil
175 | }
176 |
177 | func DeleteTodo(params graphql.ResolveParams) (interface{}, error) {
178 | database := params.Context.Value("database").(*pg.DB)
179 | user := params.Context.Value("user").(*structs.Claims)
180 |
181 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
182 | return nil, fmt.Errorf("Not authenticated.")
183 | }
184 |
185 | id, _ := params.Args["id"].(int)
186 |
187 | toDelete := structs.Todo{ID: id}
188 | if err := database.Select(&toDelete); err != nil {
189 | return nil, err
190 | }
191 |
192 | if toDelete.UserID != user.ID {
193 | return nil, fmt.Errorf("Not authrorized.")
194 | }
195 |
196 | if err := database.Delete(&toDelete); err != nil {
197 | return nil, err
198 | }
199 |
200 | return toDelete, nil
201 | }
202 |
203 | func DeleteCompletedTodos(params graphql.ResolveParams) (interface{}, error) {
204 | database := params.Context.Value("database").(*pg.DB)
205 | user := params.Context.Value("user").(*structs.Claims)
206 |
207 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
208 | return nil, fmt.Errorf("Not authenticated.")
209 | }
210 |
211 | res, err := database.Exec(`
212 | DELETE FROM todos
213 | WHERE user_id = ? AND completed IS TRUE
214 | `, user.ID)
215 |
216 | if err != nil {
217 | return nil, err
218 | }
219 |
220 | return res.RowsAffected(), nil
221 | }
222 |
--------------------------------------------------------------------------------
/src/resolvers/user.go:
--------------------------------------------------------------------------------
1 | package resolvers
2 |
3 | import (
4 | "fmt"
5 | "time"
6 |
7 | "github.com/dgrijalva/jwt-go"
8 | "github.com/go-pg/pg"
9 | "github.com/graphql-go/graphql"
10 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
11 | "github.com/jacob-ebey/go-graphql-boilerplate/src/structs"
12 | "golang.org/x/crypto/bcrypt"
13 | )
14 |
15 | var LoginError = fmt.Errorf("Email or password is invalid.")
16 | var UserExistsError = fmt.Errorf("User with the provided email already exists.")
17 | var PasswordsDoNotMatchError = fmt.Errorf("The provided passwords do not match.")
18 |
19 | func hashPassword(password string) (string, error) {
20 | bytes, err := bcrypt.GenerateFromPassword([]byte(password), 14)
21 | return string(bytes), err
22 | }
23 |
24 | func checkPasswordHash(password, hash string) bool {
25 | err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
26 | return err == nil
27 | }
28 |
29 | func createToken(user *structs.User) (string, error) {
30 | issuedAt := time.Now()
31 | expiresAt := issuedAt.Add(10 * time.Minute)
32 |
33 | t := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), structs.Claims{
34 | StandardClaims: jwt.StandardClaims{
35 | IssuedAt: issuedAt.Unix(),
36 | ExpiresAt: expiresAt.Unix(),
37 | },
38 | ID: user.ID,
39 | Email: user.Email,
40 | })
41 |
42 | return t.SignedString(config.GetJwtSecret())
43 | }
44 |
45 | func createRefreshToken(user *structs.User) (string, error) {
46 | issuedAt := time.Now()
47 | expiresAt := issuedAt.Add(24 * time.Hour)
48 |
49 | t := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), structs.Claims{
50 | StandardClaims: jwt.StandardClaims{
51 | IssuedAt: issuedAt.Unix(),
52 | ExpiresAt: expiresAt.Unix(),
53 | },
54 | ID: user.ID,
55 | })
56 |
57 | return t.SignedString(config.GetJwtSecret())
58 | }
59 |
60 | func SignIn(params graphql.ResolveParams) (interface{}, error) {
61 | database := params.Context.Value("database").(*pg.DB)
62 |
63 | email, _ := params.Args["email"].(string)
64 | password, _ := params.Args["password"].(string)
65 |
66 | user := structs.User{}
67 | if err := database.
68 | Model(&user).
69 | Where("email = ?", email).
70 | Select(); err != nil {
71 | return nil, LoginError
72 | }
73 |
74 | if !checkPasswordHash(password, user.Password) {
75 | return nil, LoginError
76 | }
77 |
78 | token, err := createToken(&user)
79 | if err != nil {
80 | return nil, err
81 | }
82 |
83 | refreshToken, err := createRefreshToken(&user)
84 | if err != nil {
85 | return nil, err
86 | }
87 |
88 | return structs.AuthResponse{
89 | RefreshToken: refreshToken,
90 | Token: token,
91 | User: &user,
92 | }, nil
93 | }
94 |
95 | func SignUp(params graphql.ResolveParams) (interface{}, error) {
96 | database := params.Context.Value("database").(*pg.DB)
97 |
98 | email, _ := params.Args["email"].(string)
99 | password, _ := params.Args["password"].(string)
100 | confirmPassword, _ := params.Args["confirmPassword"].(string)
101 |
102 | if password != confirmPassword {
103 | return nil, PasswordsDoNotMatchError
104 | }
105 |
106 | user := structs.User{}
107 | if err := database.
108 | Model(&user).
109 | Where("email = ?", email).
110 | Select(); err == nil {
111 | return nil, UserExistsError
112 | }
113 |
114 | hashedPassword, err := hashPassword(password)
115 |
116 | if err != nil {
117 | return nil, err
118 | }
119 |
120 | user = structs.User{
121 | Email: email,
122 | Password: hashedPassword,
123 | }
124 |
125 | if err := database.Insert(&user); err != nil {
126 | return nil, err
127 | }
128 |
129 | token, err := createToken(&user)
130 | if err != nil {
131 | return nil, err
132 | }
133 |
134 | refreshToken, err := createRefreshToken(&user)
135 | if err != nil {
136 | return nil, err
137 | }
138 |
139 | return structs.AuthResponse{
140 | RefreshToken: refreshToken,
141 | Token: token,
142 | User: &user,
143 | }, nil
144 | }
145 |
146 | func RefreshToken(params graphql.ResolveParams) (interface{}, error) {
147 | database := params.Context.Value("database").(*pg.DB)
148 | user := params.Context.Value("user").(*structs.Claims)
149 |
150 | if !user.VerifyExpiresAt(time.Now().Unix(), true) {
151 | return nil, fmt.Errorf("Not authenticated.")
152 | }
153 |
154 | fullUser := structs.User{ID: user.ID}
155 | if err := database.Select(&fullUser); err != nil {
156 | return nil, err
157 | }
158 |
159 | token, err := createToken(&fullUser)
160 | if err != nil {
161 | return nil, err
162 | }
163 |
164 | refreshToken, err := createRefreshToken(&fullUser)
165 | if err != nil {
166 | return nil, err
167 | }
168 |
169 | return structs.AuthResponse{
170 | RefreshToken: refreshToken,
171 | Token: token,
172 | User: &fullUser,
173 | }, nil
174 | }
175 |
--------------------------------------------------------------------------------
/src/runtime/handler.go:
--------------------------------------------------------------------------------
1 | package runtime
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 |
8 | "github.com/go-pg/pg"
9 | "github.com/graphql-go/handler"
10 |
11 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
12 | "github.com/jacob-ebey/go-graphql-boilerplate/src/db"
13 | "github.com/jacob-ebey/go-graphql-boilerplate/src/types"
14 | )
15 |
16 | type Executor struct {
17 | Handler *handler.Handler
18 | Context context.Context
19 | Close func() error
20 | }
21 |
22 | func NewExecutor(pgOptions *pg.Options, dev bool) Executor {
23 | database, err := db.Connect(pgOptions, dev)
24 | if err != nil {
25 | panic(err)
26 | }
27 |
28 | ctx := context.WithValue(context.Background(), "database", database)
29 |
30 | fmt.Println("Database connected")
31 |
32 | return Executor{
33 | handler.New(
34 | &handler.Config{
35 | Schema: &types.SchemaType,
36 | Pretty: true,
37 | GraphiQL: false,
38 | Playground: true,
39 | },
40 | ),
41 | ctx,
42 | database.Close,
43 | }
44 | }
45 |
46 | func NewHandler(pgOptions *pg.Options, dev bool) (func(w http.ResponseWriter, r *http.Request), func() error) {
47 | executor := NewExecutor(pgOptions, dev)
48 |
49 | handler := func(w http.ResponseWriter, r *http.Request) {
50 | authHeader := r.Header.Get(config.JwtHeader)
51 | user := GetUserFromToken(authHeader)
52 | ctx := context.WithValue(executor.Context, "user", user)
53 |
54 | executor.Handler.ContextHandler(ctx, w, r)
55 | }
56 |
57 | return handler, executor.Close
58 | }
59 |
--------------------------------------------------------------------------------
/src/runtime/jwt.go:
--------------------------------------------------------------------------------
1 | package runtime
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/dgrijalva/jwt-go"
7 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
8 | "github.com/jacob-ebey/go-graphql-boilerplate/src/structs"
9 | )
10 |
11 | func GetUserFromToken(token string) *structs.Claims {
12 | parsed, err := jwt.ParseWithClaims(config.GetToken(token), &structs.Claims{}, func(token *jwt.Token) (interface{}, error) {
13 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
14 | return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
15 | }
16 |
17 | return []byte(config.GetJwtSecret()), nil
18 | })
19 |
20 | // TODO: See if there is a better way to handle unauthenticated than returning an expired MapClaims
21 | if err != nil {
22 | return &structs.Claims{
23 | StandardClaims: jwt.StandardClaims{
24 | ExpiresAt: 0,
25 | },
26 | }
27 | }
28 |
29 | if claims, ok := parsed.Claims.(*structs.Claims); ok && parsed.Valid {
30 | return claims
31 | }
32 |
33 | return &structs.Claims{
34 | StandardClaims: jwt.StandardClaims{
35 | ExpiresAt: 0,
36 | },
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/structs/auth.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | import "github.com/dgrijalva/jwt-go"
4 |
5 | type Claims struct {
6 | jwt.StandardClaims
7 | ID int `json:"id"`
8 | Email string `json:"email"`
9 | }
10 |
11 | type AuthResponse struct {
12 | RefreshToken string
13 | Token string
14 | User *User
15 | }
16 |
--------------------------------------------------------------------------------
/src/structs/todo.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | type Todo struct {
4 | ID int
5 | Text string
6 | Completed bool
7 | UserID int
8 | }
9 |
--------------------------------------------------------------------------------
/src/structs/user.go:
--------------------------------------------------------------------------------
1 | package structs
2 |
3 | type User struct {
4 | ID int
5 | Email string
6 | Password string
7 | Todos []*Todo
8 | }
9 |
--------------------------------------------------------------------------------
/src/test/assert.go:
--------------------------------------------------------------------------------
1 | package test
2 |
3 | import (
4 | "reflect"
5 | "runtime/debug"
6 | "testing"
7 | )
8 |
9 | func AssertEqual(t *testing.T, value interface{}, expected interface{}) {
10 | if value != expected {
11 | debug.PrintStack()
12 | t.Errorf("Received %v (type %v), expected %v (type %v)", value, reflect.TypeOf(value), expected, reflect.TypeOf(expected))
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/types/auth-response.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
6 | )
7 |
8 | var AuthResponseType = graphql.NewObject(graphql.ObjectConfig{
9 | Name: "AuthResponse",
10 | Fields: graphql.Fields{
11 | "refreshToken": &graphql.Field{
12 | Type: graphql.String,
13 | Description: "The token to include in the '" + config.JwtHeader + "' header with the 'refreshToken' mutation. Example: '" + config.JwtType + " yourtokenhere'",
14 | },
15 | "token": &graphql.Field{
16 | Type: graphql.String,
17 | Description: "The auth token to include in the '" + config.JwtHeader + "' header. Example: '" + config.JwtType + " yourtokenhere'",
18 | },
19 | "user": &graphql.Field{
20 | Type: UserType,
21 | Description: "The user the token is for.",
22 | },
23 | },
24 | })
25 |
--------------------------------------------------------------------------------
/src/types/mutation.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 |
6 | "github.com/jacob-ebey/go-graphql-boilerplate/src/resolvers"
7 | )
8 |
9 | var MutationType = graphql.NewObject(
10 | graphql.ObjectConfig{
11 | Name: "Mutation",
12 | Fields: graphql.Fields{
13 | "createTodo": &graphql.Field{
14 | Type: TodoType,
15 | Description: "Create a new todo.",
16 | Args: graphql.FieldConfigArgument{
17 | "text": &graphql.ArgumentConfig{
18 | Type: graphql.NewNonNull(graphql.String),
19 | },
20 | },
21 | Resolve: resolvers.CreateTodo,
22 | },
23 |
24 | "editTodo": &graphql.Field{
25 | Type: TodoType,
26 | Description: "Update a todo.",
27 | Args: graphql.FieldConfigArgument{
28 | "id": &graphql.ArgumentConfig{
29 | Type: graphql.NewNonNull(graphql.Int),
30 | },
31 | "text": &graphql.ArgumentConfig{
32 | Type: graphql.NewNonNull(graphql.String),
33 | },
34 | },
35 | Resolve: resolvers.EditTodo,
36 | },
37 |
38 | "markTodo": &graphql.Field{
39 | Type: TodoType,
40 | Description: "Update a todo status.",
41 | Args: graphql.FieldConfigArgument{
42 | "id": &graphql.ArgumentConfig{
43 | Type: graphql.NewNonNull(graphql.Int),
44 | },
45 | "completed": &graphql.ArgumentConfig{
46 | Type: graphql.NewNonNull(graphql.Boolean),
47 | },
48 | },
49 | Resolve: resolvers.MarkTodoCompleted,
50 | },
51 |
52 | "deleteTodo": &graphql.Field{
53 | Type: TodoType,
54 | Description: "Delete a todo.",
55 | Args: graphql.FieldConfigArgument{
56 | "id": &graphql.ArgumentConfig{
57 | Type: graphql.NewNonNull(graphql.Int),
58 | },
59 | },
60 | Resolve: resolvers.DeleteTodo,
61 | },
62 |
63 | "deleteCompletedTodos": &graphql.Field{
64 | Type: graphql.NewNonNull(graphql.Int),
65 | Description: "Delete all completed todos.",
66 | Resolve: resolvers.DeleteCompletedTodos,
67 | },
68 |
69 | "signIn": &graphql.Field{
70 | Type: AuthResponseType,
71 | Description: "Sign in with username and password.",
72 | Args: graphql.FieldConfigArgument{
73 | "email": &graphql.ArgumentConfig{
74 | Type: graphql.NewNonNull(graphql.String),
75 | },
76 | "password": &graphql.ArgumentConfig{
77 | Type: graphql.NewNonNull(graphql.String),
78 | },
79 | },
80 | Resolve: resolvers.SignIn,
81 | },
82 |
83 | "signUp": &graphql.Field{
84 | Type: AuthResponseType,
85 | Description: "Sign up with email and password.",
86 | Args: graphql.FieldConfigArgument{
87 | "email": &graphql.ArgumentConfig{
88 | Type: graphql.NewNonNull(graphql.String),
89 | },
90 | "password": &graphql.ArgumentConfig{
91 | Type: graphql.NewNonNull(graphql.String),
92 | },
93 | "confirmPassword": &graphql.ArgumentConfig{
94 | Type: graphql.NewNonNull(graphql.String),
95 | },
96 | },
97 | Resolve: resolvers.SignUp,
98 | },
99 |
100 | "refreshToken": &graphql.Field{
101 | Type: AuthResponseType,
102 | Description: "Refrsh tokens for a user.",
103 | Resolve: resolvers.RefreshToken,
104 | },
105 | },
106 | },
107 | )
108 |
--------------------------------------------------------------------------------
/src/types/query.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 |
6 | "github.com/jacob-ebey/go-graphql-boilerplate/src/config"
7 | "github.com/jacob-ebey/go-graphql-boilerplate/src/resolvers"
8 | )
9 |
10 | var QueryType = graphql.NewObject(
11 | graphql.ObjectConfig{
12 | Name: "Query",
13 | Fields: graphql.Fields{
14 | "hello": &graphql.Field{
15 | Type: graphql.String,
16 | Description: "Say hello!",
17 | Args: graphql.FieldConfigArgument{
18 | "name": &graphql.ArgumentConfig{
19 | Type: graphql.String,
20 | },
21 | },
22 | Resolve: resolvers.SayHello,
23 | },
24 |
25 | "todos": &graphql.Field{
26 | Type: graphql.NewList(TodoType),
27 | Description: "List of todos.",
28 | Args: graphql.FieldConfigArgument{
29 | "skip": &graphql.ArgumentConfig{
30 | Type: graphql.Int,
31 | DefaultValue: 0,
32 | },
33 | "limit": &graphql.ArgumentConfig{
34 | Type: graphql.Int,
35 | DefaultValue: config.LimitDefault,
36 | },
37 | "filter": &graphql.ArgumentConfig{
38 | Type: graphql.NewEnum(graphql.EnumConfig{
39 | Name: "TodoFilter",
40 | Values: graphql.EnumValueConfigMap{
41 | "ALL": &graphql.EnumValueConfig{
42 | Value: "all",
43 | },
44 | "ACTIVE": &graphql.EnumValueConfig{
45 | Value: "active",
46 | },
47 | "COMPLETE": &graphql.EnumValueConfig{
48 | Value: "complete",
49 | },
50 | },
51 | }),
52 | },
53 | },
54 | Resolve: resolvers.GetTodos,
55 | },
56 |
57 | "todosLeft": &graphql.Field{
58 | Type: graphql.NewNonNull(graphql.Int),
59 | Description: "The number of todos that are not complete.",
60 | Resolve: resolvers.TodosLeft,
61 | },
62 |
63 | "todosTotal": &graphql.Field{
64 | Type: graphql.NewNonNull(graphql.Int),
65 | Description: "The number of todos, both active and complete.",
66 | Resolve: resolvers.TodosTotal,
67 | },
68 | },
69 | },
70 | )
71 |
--------------------------------------------------------------------------------
/src/types/schema.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 | )
6 |
7 | var SchemaType, _ = graphql.NewSchema(
8 | graphql.SchemaConfig{
9 | Query: QueryType,
10 | Mutation: MutationType,
11 | },
12 | )
13 |
--------------------------------------------------------------------------------
/src/types/todo.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 | )
6 |
7 | var TodoType = graphql.NewObject(
8 | graphql.ObjectConfig{
9 | Name: "Todo",
10 | Fields: graphql.Fields{
11 | "id": &graphql.Field{
12 | Type: graphql.NewNonNull(graphql.Int),
13 | },
14 | "text": &graphql.Field{
15 | Type: graphql.NewNonNull(graphql.String),
16 | },
17 | "completed": &graphql.Field{
18 | Type: graphql.Boolean,
19 | },
20 | },
21 | },
22 | )
23 |
--------------------------------------------------------------------------------
/src/types/user.go:
--------------------------------------------------------------------------------
1 | package types
2 |
3 | import (
4 | "github.com/graphql-go/graphql"
5 | )
6 |
7 | var UserType = graphql.NewObject(
8 | graphql.ObjectConfig{
9 | Name: "User",
10 | Fields: graphql.Fields{
11 | "id": &graphql.Field{
12 | Type: graphql.NewNonNull(graphql.Int),
13 | },
14 | "email": &graphql.Field{
15 | Type: graphql.NewNonNull(graphql.String),
16 | },
17 | },
18 | },
19 | )
20 |
--------------------------------------------------------------------------------