├── .gitignore ├── README.md ├── client ├── .env.sample ├── .gitignore ├── craco.config.js ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── components │ │ ├── BookGrid │ │ │ └── index.js │ │ ├── Button │ │ │ └── index.js │ │ ├── Loader │ │ │ └── index.js │ │ ├── MainLayout │ │ │ └── index.js │ │ ├── NavBar │ │ │ └── index.js │ │ ├── PageNotice │ │ │ └── index.js │ │ ├── PrivateRoute │ │ │ └── index.js │ │ ├── PublicRoute │ │ │ └── index.js │ │ ├── ReviewList │ │ │ └── index.js │ │ ├── SearchBooksForm │ │ │ └── index.js │ │ ├── Select │ │ │ └── index.js │ │ └── TextInput │ │ │ └── index.js │ ├── context │ │ └── AuthContext │ │ │ └── index.js │ ├── graphql │ │ ├── apollo.js │ │ ├── fragments.js │ │ ├── links │ │ │ ├── WebSocketLink.js │ │ │ └── authErrorLink.js │ │ ├── mutations.js │ │ ├── queries.js │ │ ├── subscriptions.js │ │ └── typePolicies.js │ ├── index.css │ ├── index.js │ ├── pages │ │ ├── Book │ │ │ └── index.js │ │ ├── Home │ │ │ └── index.js │ │ ├── Index │ │ │ └── index.js │ │ ├── Login │ │ │ └── index.js │ │ ├── NewBook │ │ │ └── index.js │ │ ├── ReviewBook │ │ │ └── index.js │ │ └── Search │ │ │ └── index.js │ ├── router │ │ └── index.js │ ├── setupProxy.js │ └── utils │ │ └── updateQueries.js └── tailwind.config.js └── server ├── .env.sample ├── package-lock.json ├── package.json ├── routes.json └── src ├── graphql ├── dataSources │ └── JsonServerApi.js ├── directives │ └── UniqueDirective.js ├── permissions.js ├── plugins │ └── cookieHeaderPlugin.js ├── resolvers.js ├── scalars │ ├── DateTimeType.js │ └── RatingType.js └── typeDefs.js ├── index.js └── utils ├── passwords.js └── tokens.js /.gitignore: -------------------------------------------------------------------------------- 1 | # editor 2 | 3 | .vscode 4 | 5 | # dependencies 6 | 7 | node_modules 8 | 9 | # environment 10 | 11 | .env 12 | .env.local 13 | .env.development.local 14 | .env.test.local 15 | .env.production.local 16 | 17 | # databases 18 | 19 | server/db.json 20 | 21 | # client 22 | 23 | client/coverage 24 | cilent/build 25 | 26 | # logs 27 | 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | 32 | # misc 33 | 34 | .DS_Store 35 | .DS_Store? 36 | ._* 37 | .Spotlight-V100 38 | .Trashes 39 | ehthumbs.db 40 | *[Tt]humbs.db 41 | *.Trashes -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Get Going with GraphQL (Source Code) 2 | 3 | This repo contains the completed files for the Node.js and React applications built throughout the _Get Going with GraphQL_ book from 8-Bit Press. 4 | 5 | **[Get the book package here](https://github.com/8bitpress/get-going-with-graphql)** 6 | 7 | Happy coding! 8 | 9 | --- 10 | 11 | Copyright © 2022 8-Bit Press Inc. 12 | -------------------------------------------------------------------------------- /client/.env.sample: -------------------------------------------------------------------------------- 1 | REACT_APP_GRAPHQL_ENDPOINT=http://localhost:3000/graphql 2 | REACT_APP_SUBSCRIPTIONS_ENDPOINT=ws://localhost:3000/graphql -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 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 | -------------------------------------------------------------------------------- /client/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: [require("tailwindcss"), require("autoprefixer")] 5 | } 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "^3.3.11", 7 | "@craco/craco": "^6.1.1", 8 | "@testing-library/jest-dom": "^5.11.9", 9 | "@testing-library/react": "^11.2.5", 10 | "@testing-library/user-event": "^12.8.1", 11 | "dayjs": "^1.10.4", 12 | "graphql": "^15.5.0", 13 | "graphql-ws": "^4.3.2", 14 | "history": "^4.10.1", 15 | "http-proxy-middleware": "^1.1.0", 16 | "jwt-decode": "^3.1.2", 17 | "query-string": "^7.0.0", 18 | "react": "^17.0.1", 19 | "react-dom": "^17.0.1", 20 | "react-router-dom": "^5.2.0", 21 | "react-scripts": "4.0.3", 22 | "web-vitals": "^1.1.0" 23 | }, 24 | "scripts": { 25 | "start": "craco start", 26 | "build": "craco build", 27 | "test": "craco test", 28 | "eject": "react-scripts eject" 29 | }, 30 | "eslintConfig": { 31 | "extends": [ 32 | "react-app", 33 | "react-app/jest" 34 | ] 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "devDependencies": { 49 | "@tailwindcss/postcss7-compat": "^2.0.3", 50 | "autoprefixer": "^9.8.6", 51 | "postcss": "^7.0.34", 52 | "tailwindcss": "npm:@tailwindcss/postcss7-compat@^2.0.3" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8bitpress/get-going-with-graphql-source-code/2d681d0f6010678eabd63ef16d243d3d13bbc676/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Bibliotech 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8bitpress/get-going-with-graphql-source-code/2d681d0f6010678eabd63ef16d243d3d13bbc676/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/8bitpress/get-going-with-graphql-source-code/2d681d0f6010678eabd63ef16d243d3d13bbc676/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/components/BookGrid/index.js: -------------------------------------------------------------------------------- 1 | import { useHistory } from "react-router-dom"; 2 | 3 | function BookGrid({ books }) { 4 | const history = useHistory(); 5 | 6 | return ( 7 | 36 | ); 37 | } 38 | 39 | export default BookGrid; 40 | -------------------------------------------------------------------------------- /client/src/components/Button/index.js: -------------------------------------------------------------------------------- 1 | function Button({ className, disabled, onClick, text, type }) { 2 | let buttonClasses = 3 | "bg-red-500 hover:bg-red-700 font-semibold rounded px-4 py-2 shadow hover:shadow-md focus:outline-none focus:shadow-outline text-white text-sm sm:text-base"; 4 | 5 | if (className) { 6 | buttonClasses = `${buttonClasses} ${className}`; 7 | } 8 | 9 | if (disabled) { 10 | buttonClasses = `${buttonClasses} cursor-not-allowed`; 11 | } 12 | 13 | return ( 14 | 22 | ); 23 | } 24 | 25 | Button.defaultProps = { 26 | disabled: false, 27 | onClick: () => {}, 28 | type: "button" 29 | }; 30 | 31 | export default Button; 32 | -------------------------------------------------------------------------------- /client/src/components/Loader/index.js: -------------------------------------------------------------------------------- 1 | function Loader({ centered }) { 2 | return ( 3 |
6 |
7 |
8 | ); 9 | } 10 | 11 | export default Loader; 12 | -------------------------------------------------------------------------------- /client/src/components/MainLayout/index.js: -------------------------------------------------------------------------------- 1 | import NavBar from "../NavBar"; 2 | 3 | function MainLayout({ children }) { 4 | return ( 5 |
6 |
7 | 8 |
9 | {children} 10 |
11 | 19 |
20 |
21 | ); 22 | } 23 | 24 | export default MainLayout; 25 | -------------------------------------------------------------------------------- /client/src/components/NavBar/index.js: -------------------------------------------------------------------------------- 1 | import { Link, useHistory } from "react-router-dom"; 2 | import { useMutation } from "@apollo/client"; 3 | 4 | import { Logout } from "../../graphql/mutations"; 5 | import { useAuth } from "../../context/AuthContext"; 6 | import Button from "../Button"; 7 | 8 | function NavBar() { 9 | const { clearSessionData, isAuthenticated } = useAuth(); 10 | const history = useHistory(); 11 | 12 | const [logout] = useMutation(Logout, { 13 | onCompleted: () => { 14 | clearSessionData(); 15 | history.push("/login"); 16 | } 17 | }); 18 | 19 | return ( 20 |
21 |
22 | 26 |

Bibliotech

27 | 28 |
29 | {isAuthenticated() && ( 30 | <> 31 | 32 | 33 | My Library 34 | 35 | 36 | 37 | 38 | Add Book 39 | 40 | 41 | 42 | )} 43 |
56 |
57 |
58 | ); 59 | } 60 | 61 | export default NavBar; 62 | -------------------------------------------------------------------------------- /client/src/components/PageNotice/index.js: -------------------------------------------------------------------------------- 1 | function PageNotice({ text }) { 2 | return ( 3 |
4 |

{text}

5 |
6 | ); 7 | } 8 | 9 | export default PageNotice; 10 | -------------------------------------------------------------------------------- /client/src/components/PrivateRoute/index.js: -------------------------------------------------------------------------------- 1 | import { Redirect, Route, useLocation } from "react-router-dom"; 2 | import { useEffect } from "react"; 3 | 4 | import { useAuth } from "../../context/AuthContext"; 5 | import Loader from "../../components/Loader"; 6 | 7 | function PrivateRoute({ component: Component, ...rest }) { 8 | const { checkingSession, isAuthenticated } = useAuth(); 9 | const { pathname } = useLocation(); 10 | 11 | const renderRoute = props => { 12 | if (checkingSession) { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } else if (isAuthenticated()) { 19 | return ; 20 | } 21 | 22 | return ; 23 | }; 24 | 25 | useEffect(() => { 26 | window.scrollTo(0, 0); 27 | }, [pathname]); 28 | 29 | return ; 30 | } 31 | 32 | export default PrivateRoute; 33 | -------------------------------------------------------------------------------- /client/src/components/PublicRoute/index.js: -------------------------------------------------------------------------------- 1 | import { Route, useLocation } from "react-router-dom"; 2 | import { useEffect } from "react"; 3 | 4 | import { useAuth } from "../../context/AuthContext"; 5 | import Loader from "../../components/Loader"; 6 | 7 | function PublicRoute({ component: Component, ...rest }) { 8 | const { checkingSession } = useAuth(); 9 | const { pathname } = useLocation(); 10 | 11 | const renderRoute = props => { 12 | if (checkingSession) { 13 | return ( 14 |
15 | 16 |
17 | ); 18 | } 19 | 20 | return ; 21 | }; 22 | 23 | useEffect(() => { 24 | window.scrollTo(0, 0); 25 | }, [pathname]); 26 | 27 | return ; 28 | } 29 | 30 | export default PublicRoute; 31 | -------------------------------------------------------------------------------- /client/src/components/ReviewList/index.js: -------------------------------------------------------------------------------- 1 | import { useHistory } from "react-router-dom"; 2 | import { useMutation } from "@apollo/client"; 3 | import dayjs from "dayjs"; 4 | 5 | import { DeleteReview } from "../../graphql/mutations"; 6 | import Button from "../../components/Button"; 7 | 8 | function ReviewsList({ bookId, reviews, viewerId }) { 9 | const history = useHistory(); 10 | 11 | const [deleteReview] = useMutation(DeleteReview, { 12 | update: (cache, { data: { deleteReview } }) => { 13 | cache.modify({ 14 | id: `Book:${bookId}`, 15 | fields: { 16 | reviews(existingReviewRefs, { readField }) { 17 | return existingReviewRefs.results.filter( 18 | reviewRef => deleteReview !== readField("id", reviewRef) 19 | ); 20 | } 21 | } 22 | }); 23 | cache.evict({ id: `Review:${deleteReview}` }); 24 | } 25 | }); 26 | 27 | return reviews.map(({ id, rating, reviewedOn, reviewer, text }) => ( 28 |
29 |
30 |
31 |

32 | 33 | {reviewer.name} 34 | {" "} 35 | {rating && `rated this book ${rating}/5`} 36 |

37 |

38 | Reviewed on {dayjs(reviewedOn).format("MMMM D, YYYY")} 39 |

40 |
41 | {viewerId === reviewer.id && ( 42 |
43 | 60 |
61 | )} 62 |
63 |

{text}

64 |
65 | )); 66 | } 67 | 68 | export default ReviewsList; 69 | -------------------------------------------------------------------------------- /client/src/components/SearchBooksForm/index.js: -------------------------------------------------------------------------------- 1 | import { useHistory, useLocation } from "react-router-dom"; 2 | import { useState } from "react"; 3 | import queryString from "query-string"; 4 | 5 | import Button from "../Button"; 6 | import TextInput from "../TextInput"; 7 | 8 | function SearchBooksForm() { 9 | const history = useHistory(); 10 | const location = useLocation(); 11 | 12 | const { q } = queryString.parse(location.search); 13 | const [query, setQuery] = useState(q || ""); 14 | 15 | return ( 16 |
17 |
{ 20 | event.preventDefault(); 21 | history.push(`/search?q=${query}`); 22 | }} 23 | > 24 | { 32 | setQuery(event.target.value); 33 | }} 34 | placeholder="Search for a book title or author" 35 | type="search" 36 | value={query} 37 | /> 38 |
41 | ); 42 | } 43 | 44 | export default SearchBooksForm; 45 | -------------------------------------------------------------------------------- /client/src/components/Select/index.js: -------------------------------------------------------------------------------- 1 | function Select({ 2 | className, 3 | hiddenLabel, 4 | id, 5 | label, 6 | name, 7 | onChange, 8 | options, 9 | selectWidthClass, 10 | value, 11 | ...rest 12 | }) { 13 | return ( 14 |
15 | 21 |
22 | 36 |
37 | 42 | 43 | 44 |
45 |
46 |
47 | ); 48 | } 49 | 50 | export default Select; 51 | -------------------------------------------------------------------------------- /client/src/components/TextInput/index.js: -------------------------------------------------------------------------------- 1 | function TextInput({ 2 | className, 3 | error, 4 | hiddenLabel, 5 | id, 6 | inputWidthClass, 7 | label, 8 | name, 9 | onChange, 10 | placeholder, 11 | type, 12 | value, 13 | ...rest 14 | }) { 15 | return ( 16 |
17 | 23 | 32 | {error &&

{error}

} 33 |
34 | ); 35 | } 36 | 37 | TextInput.defaultProps = { 38 | inputWidthClass: "w-auto", 39 | type: "text" 40 | }; 41 | 42 | export default TextInput; 43 | -------------------------------------------------------------------------------- /client/src/context/AuthContext/index.js: -------------------------------------------------------------------------------- 1 | import { useApolloClient } from "@apollo/client"; 2 | import { createContext, useContext, useEffect, useState } from "react"; 3 | import jwtDecode from "jwt-decode"; 4 | 5 | import { GetViewer } from "../../graphql/queries"; 6 | 7 | const AuthContext = createContext(); 8 | const useAuth = () => useContext(AuthContext); 9 | 10 | function AuthProvider({ children }) { 11 | const [checkingSession, setCheckingSession] = useState(true); 12 | const [error, setError] = useState(); 13 | const [viewer, setViewer] = useState(); 14 | const client = useApolloClient(); 15 | 16 | const persistSessionData = authPayload => { 17 | if (authPayload.token && authPayload.viewer) { 18 | const decodedToken = jwtDecode(authPayload.token); 19 | const expiresAt = decodedToken.exp * 1000; 20 | localStorage.setItem("token_expires_at", expiresAt); 21 | setViewer(authPayload.viewer); 22 | } 23 | }; 24 | 25 | const clearSessionData = () => { 26 | localStorage.removeItem("token_expires_at"); 27 | setViewer(null); 28 | }; 29 | 30 | const isAuthenticated = () => { 31 | const expiresAt = localStorage.getItem("token_expires_at"); 32 | return expiresAt ? new Date().getTime() < expiresAt : false; 33 | }; 34 | 35 | useEffect(() => { 36 | const getViewerIfAuthenticated = async () => { 37 | if (isAuthenticated() && !viewer) { 38 | try { 39 | const { data, error: viewerError, loading } = await client.query({ 40 | query: GetViewer 41 | }); 42 | 43 | if (!loading && viewerError) { 44 | setError(viewerError); 45 | } else if (!loading && data) { 46 | setViewer(data.viewer); 47 | } 48 | } catch (err) { 49 | setError(err); 50 | } 51 | } 52 | setCheckingSession(false); 53 | }; 54 | getViewerIfAuthenticated(); 55 | }, [client, viewer]); 56 | 57 | return ( 58 | 68 | {children} 69 | 70 | ); 71 | } 72 | 73 | export { AuthProvider, useAuth }; 74 | -------------------------------------------------------------------------------- /client/src/graphql/apollo.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient, HttpLink, InMemoryCache, split } from "@apollo/client"; 2 | import { getMainDefinition } from "@apollo/client/utilities"; 3 | 4 | import authErrorLink from "./links/authErrorLink"; 5 | import typePolicies from "./typePolicies"; 6 | import WebSocketLink from "./links/WebSocketLink"; 7 | 8 | const httpLink = new HttpLink({ 9 | uri: process.env.REACT_APP_GRAPHQL_ENDPOINT 10 | }); 11 | 12 | const wsLink = new WebSocketLink({ 13 | url: process.env.REACT_APP_SUBSCRIPTIONS_ENDPOINT 14 | }); 15 | 16 | const link = split( 17 | ({ query }) => { 18 | const definition = getMainDefinition(query); 19 | return ( 20 | definition.kind === "OperationDefinition" && 21 | definition.operation === "subscription" 22 | ); 23 | }, 24 | wsLink, 25 | authErrorLink.concat(httpLink) 26 | ); 27 | 28 | const client = new ApolloClient({ 29 | cache: new InMemoryCache({ typePolicies }), 30 | connectToDevTools: true, 31 | link 32 | }); 33 | 34 | export default client; 35 | -------------------------------------------------------------------------------- /client/src/graphql/fragments.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export const basicBook = gql` 4 | fragment basicBook on Book { 5 | authors { 6 | id 7 | name 8 | } 9 | cover 10 | id 11 | title 12 | } 13 | `; 14 | 15 | export const fullReview = gql` 16 | fragment fullReview on Review { 17 | id 18 | book { 19 | id 20 | } 21 | reviewedOn 22 | rating 23 | reviewer { 24 | id 25 | name 26 | } 27 | text 28 | } 29 | `; 30 | 31 | export const viewerAndToken = gql` 32 | fragment viewerAndToken on AuthPayload { 33 | viewer { 34 | id 35 | email 36 | name 37 | username 38 | } 39 | token 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /client/src/graphql/links/WebSocketLink.js: -------------------------------------------------------------------------------- 1 | import { ApolloLink, Observable } from "@apollo/client"; 2 | import { createClient } from "graphql-ws"; 3 | import { print } from "graphql"; 4 | 5 | class WebSocketLink extends ApolloLink { 6 | constructor(options) { 7 | super(); 8 | this.client = createClient(options); 9 | } 10 | 11 | request(operation) { 12 | return new Observable(sink => { 13 | return this.client.subscribe( 14 | { ...operation, query: print(operation.query) }, 15 | { 16 | next: sink.next.bind(sink), 17 | complete: sink.complete.bind(sink), 18 | error: err => { 19 | if (err instanceof Error) { 20 | return sink.error(err); 21 | } 22 | 23 | if (err instanceof CloseEvent) { 24 | return sink.error( 25 | new Error( 26 | `Socket closed with event ${err.code} ${err.reason || ""}` 27 | ) 28 | ); 29 | } 30 | 31 | return sink.error( 32 | new Error(err.map(({ message }) => message).join(", ")) 33 | ); 34 | } 35 | } 36 | ); 37 | }); 38 | } 39 | } 40 | 41 | export default WebSocketLink; 42 | -------------------------------------------------------------------------------- /client/src/graphql/links/authErrorLink.js: -------------------------------------------------------------------------------- 1 | import { onError } from "@apollo/client/link/error"; 2 | 3 | import { history } from "../../router"; 4 | 5 | const authErrorLink = onError(({ graphQLErrors }) => { 6 | if (graphQLErrors) { 7 | const notAuthorizedError = graphQLErrors.find( 8 | error => error.message === "Not Authorised!" 9 | ); 10 | 11 | if (notAuthorizedError) { 12 | const expiresAt = localStorage.getItem("token_expires_at"); 13 | const isAuthenticated = expiresAt 14 | ? new Date().getTime() < expiresAt 15 | : false; 16 | 17 | if (!isAuthenticated) { 18 | history.push("/login"); 19 | } 20 | } 21 | } 22 | }); 23 | 24 | export default authErrorLink; 25 | -------------------------------------------------------------------------------- /client/src/graphql/mutations.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | import { fullReview, viewerAndToken } from "./fragments"; 4 | 5 | export const AddBooksToLibrary = gql` 6 | mutation AddBooksToLibrary($input: UpdateLibraryBooksInput!) { 7 | addBooksToLibrary(input: $input) { 8 | id 9 | } 10 | } 11 | `; 12 | 13 | export const CreateBookAndAuthors = gql` 14 | mutation CreateBookAndAuthors($input: CreateBookAndAuthorsInput!) { 15 | createBookAndAuthors(input: $input) { 16 | id 17 | authors { 18 | name 19 | } 20 | cover 21 | genre 22 | title 23 | } 24 | } 25 | `; 26 | 27 | export const CreateReview = gql` 28 | mutation CreateReview($input: CreateReviewInput!) { 29 | createReview(input: $input) { 30 | ...fullReview 31 | } 32 | } 33 | ${fullReview} 34 | `; 35 | 36 | export const DeleteReview = gql` 37 | mutation DeleteReview($id: ID!) { 38 | deleteReview(id: $id) 39 | } 40 | `; 41 | 42 | export const Login = gql` 43 | mutation Login($password: String!, $username: String!) { 44 | login(password: $password, username: $username) { 45 | ...viewerAndToken 46 | } 47 | } 48 | ${viewerAndToken} 49 | `; 50 | 51 | export const Logout = gql` 52 | mutation Logout { 53 | logout 54 | } 55 | `; 56 | 57 | export const RemoveBooksFromLibrary = gql` 58 | mutation RemoveBooksFromLibrary($input: UpdateLibraryBooksInput!) { 59 | removeBooksFromLibrary(input: $input) { 60 | id 61 | } 62 | } 63 | `; 64 | 65 | export const SignUp = gql` 66 | mutation SignUp($input: SignUpInput!) { 67 | signUp(input: $input) { 68 | ...viewerAndToken 69 | } 70 | } 71 | ${viewerAndToken} 72 | `; 73 | 74 | export const UpdateReview = gql` 75 | mutation UpdateReview($input: UpdateReviewInput!) { 76 | updateReview(input: $input) { 77 | ...fullReview 78 | } 79 | } 80 | ${fullReview} 81 | `; 82 | -------------------------------------------------------------------------------- /client/src/graphql/queries.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | import { basicBook, fullReview } from "./fragments"; 4 | 5 | export const GetBook = gql` 6 | query GetBook($id: ID!, $reviewsLimit: Int, $reviewsPage: Int) { 7 | book(id: $id) { 8 | ...basicBook 9 | summary 10 | viewerHasInLibrary 11 | viewerHasReviewed 12 | reviews( 13 | limit: $reviewsLimit 14 | orderBy: REVIEWED_ON_DESC 15 | page: $reviewsPage 16 | ) { 17 | results { 18 | ...fullReview 19 | } 20 | pageInfo { 21 | hasNextPage 22 | page 23 | } 24 | } 25 | } 26 | } 27 | ${basicBook} 28 | ${fullReview} 29 | `; 30 | 31 | export const GetBooks = gql` 32 | query GetBooks($limit: Int, $page: Int) { 33 | books(limit: $limit, orderBy: TITLE_ASC, page: $page) { 34 | results { 35 | ...basicBook 36 | } 37 | pageInfo { 38 | hasNextPage 39 | page 40 | } 41 | } 42 | } 43 | ${basicBook} 44 | `; 45 | 46 | export const GetReview = gql` 47 | query GetReview($id: ID!) { 48 | review(id: $id) { 49 | id 50 | rating 51 | text 52 | } 53 | } 54 | `; 55 | 56 | export const GetViewer = gql` 57 | query GetViewer { 58 | viewer { 59 | id 60 | email 61 | name 62 | username 63 | } 64 | } 65 | `; 66 | 67 | export const GetViewerLibrary = gql` 68 | query GetViewerLibrary($limit: Int, $page: Int) { 69 | viewer { 70 | id 71 | library(limit: $limit, orderBy: ADDED_ON_DESC, page: $page) { 72 | results { 73 | ...basicBook 74 | } 75 | pageInfo { 76 | hasNextPage 77 | page 78 | } 79 | } 80 | } 81 | } 82 | ${basicBook} 83 | `; 84 | 85 | export const SearchBooks = gql` 86 | query SearchBooks($query: String!) { 87 | searchBooks(query: $query, orderBy: RESULT_ASC) { 88 | ... on Book { 89 | ...basicBook 90 | } 91 | ... on Author { 92 | books { 93 | ...basicBook 94 | } 95 | } 96 | } 97 | } 98 | ${basicBook} 99 | `; 100 | -------------------------------------------------------------------------------- /client/src/graphql/subscriptions.js: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | import { fullReview } from "./fragments"; 4 | 5 | export const ReviewAdded = gql` 6 | subscription ReviewAdded($bookId: ID!) { 7 | reviewAdded(bookId: $bookId) { 8 | ...fullReview 9 | } 10 | } 11 | ${fullReview} 12 | `; 13 | -------------------------------------------------------------------------------- /client/src/graphql/typePolicies.js: -------------------------------------------------------------------------------- 1 | function mergePageResults(keyArgs = false) { 2 | return { 3 | keyArgs, 4 | merge(existing, incoming, { args: { page, limit } }) { 5 | const { __typeName, pageInfo, results } = incoming; 6 | const mergedResults = existing?.results?.length 7 | ? existing.results.slice(0) 8 | : []; 9 | 10 | for (let i = 0; i < results.length; ++i) { 11 | mergedResults[(page - 1) * limit + i] = results[i]; 12 | } 13 | return { results: mergedResults, pageInfo, __typeName }; 14 | } 15 | }; 16 | } 17 | 18 | const typePolicies = { 19 | Book: { 20 | fields: { 21 | reviews: mergePageResults() 22 | } 23 | }, 24 | User: { 25 | fields: { 26 | library: mergePageResults() 27 | } 28 | }, 29 | Query: { 30 | fields: { 31 | books: mergePageResults() 32 | } 33 | } 34 | }; 35 | 36 | export default typePolicies; 37 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | 3 | h1 { 4 | @apply font-bold leading-none text-5xl; 5 | } 6 | 7 | h2 { 8 | @apply font-bold leading-none text-4xl text-gray-700; 9 | } 10 | 11 | h3 { 12 | @apply font-bold leading-none text-3xl text-gray-700; 13 | } 14 | 15 | .loader { 16 | @apply inline-block h-12 w-12; 17 | } 18 | 19 | .loader:after { 20 | @apply block border-4 border-solid rounded-full h-12 w-12; 21 | animation: spin 1.2s linear infinite; 22 | border-color: #cbd5e0 transparent #cbd5e0 transparent; 23 | content: " "; 24 | } 25 | 26 | @keyframes spin { 27 | 0% { 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | 35 | @tailwind components; 36 | @tailwind utilities; 37 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import { ApolloProvider } from "@apollo/client"; 2 | import { Router } from "react-router-dom"; 3 | import ReactDOM from "react-dom"; 4 | 5 | import { AuthProvider } from "./context/AuthContext"; 6 | import { history, Routes } from "./router"; 7 | import client from "./graphql/apollo"; 8 | 9 | import "./index.css"; 10 | 11 | function App() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | 23 | ReactDOM.render(, document.getElementById("root")); 24 | -------------------------------------------------------------------------------- /client/src/pages/Book/index.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useHistory, useParams } from "react-router-dom"; 3 | import { useMutation, useQuery } from "@apollo/client"; 4 | 5 | import { 6 | AddBooksToLibrary, 7 | RemoveBooksFromLibrary 8 | } from "../../graphql/mutations"; 9 | import { GetBook } from "../../graphql/queries"; 10 | import { ReviewAdded } from "../../graphql/subscriptions"; 11 | import { 12 | updateAddNewReviewToList, 13 | updateViewerHasInLibrary 14 | } from "../../utils/updateQueries"; 15 | import { useAuth } from "../../context/AuthContext"; 16 | import Button from "../../components/Button"; 17 | import Loader from "../../components/Loader"; 18 | import MainLayout from "../../components/MainLayout"; 19 | import PageNotice from "../../components/PageNotice"; 20 | import ReviewsList from "../../components/ReviewList"; 21 | 22 | function Book() { 23 | const { id } = useParams(); 24 | const { viewer } = useAuth(); 25 | const history = useHistory(); 26 | const reviewsLimit = 20; 27 | 28 | const { data, error, fetchMore, loading, subscribeToMore } = useQuery( 29 | GetBook, 30 | { 31 | variables: { id, reviewsLimit, reviewsPage: 1 }, 32 | fetchPolicy: "cache-and-network", 33 | nextFetchPolicy: "cache-first" 34 | } 35 | ); 36 | const [addBooksToLibrary] = useMutation(AddBooksToLibrary, { 37 | update: cache => { 38 | updateViewerHasInLibrary(cache, id); 39 | } 40 | }); 41 | const [removeBooksFromLibrary] = useMutation(RemoveBooksFromLibrary, { 42 | update: cache => { 43 | updateViewerHasInLibrary(cache, id); 44 | } 45 | }); 46 | 47 | useEffect(() => { 48 | const unsubscribe = subscribeToMore({ 49 | document: ReviewAdded, 50 | variables: { bookId: id }, 51 | updateQuery: (previousResult, { subscriptionData }) => 52 | updateAddNewReviewToList(previousResult, subscriptionData) 53 | }); 54 | return () => unsubscribe(); 55 | }); 56 | 57 | let content = null; 58 | 59 | if (loading && !data) { 60 | content = ; 61 | } else if (data?.book) { 62 | const { 63 | book: { 64 | authors, 65 | cover, 66 | reviews, 67 | summary, 68 | title, 69 | viewerHasInLibrary, 70 | viewerHasReviewed 71 | } 72 | } = data; 73 | 74 | content = ( 75 |
76 |
77 | {cover ? ( 78 | {`${title} 83 | ) : ( 84 |
85 | 86 | Cover image unavailable 87 | 88 |
89 | )} 90 |
91 |

{title}

92 |

{`by ${authors 93 | .map(author => author.name) 94 | .join(", ")}`}

95 | {summary ? ( 96 |

{summary}

97 | ) : ( 98 |

99 | Book summary unavailable. 100 |

101 | )} 102 | {viewer && ( 103 |
122 |
123 |
124 |
125 |

What Readers Say

126 | {viewer && !viewerHasReviewed && ( 127 |
136 | {reviews.results.length ? ( 137 |
138 | 143 | {reviews.pageInfo.hasNextPage && ( 144 |
159 | ) : ( 160 |

No reviews for this book yet!

161 | )} 162 |
163 |
164 | ); 165 | } else if (error) { 166 | content = ; 167 | } 168 | 169 | return {content}; 170 | } 171 | 172 | export default Book; 173 | -------------------------------------------------------------------------------- /client/src/pages/Home/index.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/client"; 2 | 3 | import { GetViewerLibrary } from "../../graphql/queries"; 4 | import BookGrid from "../../components/BookGrid"; 5 | import Button from "../../components/Button"; 6 | import Loader from "../../components/Loader"; 7 | import PageNotice from "../../components/PageNotice"; 8 | import MainLayout from "../../components/MainLayout"; 9 | 10 | function Home() { 11 | const limit = 12; 12 | const { data, error, fetchMore, loading } = useQuery(GetViewerLibrary, { 13 | variables: { limit, page: 1 } 14 | }); 15 | 16 | let content = null; 17 | 18 | if (loading && !data) { 19 | content = ; 20 | } else if (data?.viewer && !data.viewer.library.results.length) { 21 | content = ( 22 |
23 |

24 | Time to add some books to your library! 25 |

26 |
27 | ); 28 | } else if (data?.viewer) { 29 | const { 30 | pageInfo: { hasNextPage, page }, 31 | results 32 | } = data.viewer.library; 33 | 34 | content = ( 35 | <> 36 |
37 |

Your Library

38 | 39 |
40 | {hasNextPage && ( 41 |
42 |
52 | )} 53 | 54 | ); 55 | } else if (!data.viewer || error) { 56 | content = ; 57 | } 58 | 59 | return {content}; 60 | } 61 | 62 | export default Home; 63 | -------------------------------------------------------------------------------- /client/src/pages/Index/index.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@apollo/client"; 2 | 3 | import { GetBooks } from "../../graphql/queries"; 4 | import BookGrid from "../../components/BookGrid"; 5 | import Button from "../../components/Button"; 6 | import Loader from "../../components/Loader"; 7 | import MainLayout from "../../components/MainLayout"; 8 | import PageNotice from "../../components/PageNotice"; 9 | import SearchBooksForm from "../../components/SearchBooksForm"; 10 | 11 | function Index() { 12 | const limit = 12; 13 | const { data, error, fetchMore, loading } = useQuery(GetBooks, { 14 | variables: { limit, page: 1 } 15 | }); 16 | 17 | let content = null; 18 | 19 | if (loading && !data) { 20 | content = ; 21 | } else if (data?.books) { 22 | const { 23 | pageInfo: { hasNextPage, page }, 24 | results 25 | } = data.books; 26 | 27 | content = ( 28 | <> 29 |
30 | 31 | 32 |
33 | {hasNextPage && ( 34 |
35 |
45 | )} 46 | 47 | ); 48 | } else if (error) { 49 | content = ; 50 | } 51 | 52 | return {content}; 53 | } 54 | 55 | export default Index; 56 | -------------------------------------------------------------------------------- /client/src/pages/Login/index.js: -------------------------------------------------------------------------------- 1 | import { Redirect, useHistory } from "react-router-dom"; 2 | import { useMutation } from "@apollo/client"; 3 | import { useState } from "react"; 4 | 5 | import { Login as LoginMutation, SignUp } from "../../graphql/mutations"; 6 | import { useAuth } from "../../context/AuthContext"; 7 | import Button from "../../components/Button"; 8 | import TextInput from "../../components/TextInput"; 9 | 10 | function Login() { 11 | const [email, setEmail] = useState(""); 12 | const [isMember, setIsMember] = useState(true); 13 | const [name, setName] = useState(""); 14 | const [password, setPassword] = useState(""); 15 | const [username, setUsername] = useState(""); 16 | const { isAuthenticated, persistSessionData } = useAuth(); 17 | const history = useHistory(); 18 | 19 | const onCompleted = data => { 20 | const { token, viewer } = Object.entries(data)[0][1]; 21 | persistSessionData({ token, viewer }); 22 | history.push("/home"); 23 | }; 24 | 25 | const [login, { error, loading }] = useMutation(LoginMutation, { 26 | onCompleted 27 | }); 28 | const [signUp] = useMutation(SignUp, { onCompleted }); 29 | 30 | return isAuthenticated() ? ( 31 | 32 | ) : ( 33 |
34 |
35 |

Welcome to Bibliotech

36 |
{ 38 | event.preventDefault(); 39 | 40 | if (isMember) { 41 | await login({ variables: { password, username } }).catch(err => { 42 | console.error(err); 43 | }); 44 | } else { 45 | await signUp({ 46 | variables: { input: { email, name, password, username } } 47 | }).catch(err => { 48 | console.error(err); 49 | }); 50 | } 51 | }} 52 | > 53 | { 60 | setUsername(event.target.value); 61 | }} 62 | placeholder="Your username" 63 | type="text" 64 | value={username} 65 | /> 66 | {!isMember && ( 67 | <> 68 | { 75 | setEmail(event.target.value); 76 | }} 77 | placeholder="Your email" 78 | type="email" 79 | value={email} 80 | /> 81 | { 88 | setName(event.target.value); 89 | }} 90 | placeholder="Your full name" 91 | type="text" 92 | value={name} 93 | /> 94 | 95 | )} 96 | { 103 | setPassword(event.target.value); 104 | }} 105 | placeholder="Your password" 106 | type="password" 107 | value={password} 108 | /> 109 |
110 | 128 |
129 | {error && ( 130 |

{error.message}

131 | )} 132 | 133 |
134 |
135 | ); 136 | } 137 | 138 | export default Login; 139 | -------------------------------------------------------------------------------- /client/src/pages/NewBook/index.js: -------------------------------------------------------------------------------- 1 | import { useHistory } from "react-router-dom"; 2 | import { useMutation } from "@apollo/client"; 3 | import { useState } from "react"; 4 | 5 | import { CreateBookAndAuthors } from "../../graphql/mutations"; 6 | import { useAuth } from "../../context/AuthContext"; 7 | import Button from "../../components/Button"; 8 | import MainLayout from "../../components/MainLayout"; 9 | import PageNotice from "../../components/PageNotice"; 10 | import Select from "../../components/Select"; 11 | import TextInput from "../../components/TextInput"; 12 | 13 | function NewBook() { 14 | const [title, setTitle] = useState(""); 15 | const [authors, setAuthors] = useState(""); 16 | const [cover, setCover] = useState(""); 17 | const [genre, setGenre] = useState(""); 18 | const [summary, setSummary] = useState(""); 19 | const [titleError, setTitleError] = useState(); 20 | 21 | const { viewer, error: viewerError } = useAuth(); 22 | const history = useHistory(); 23 | 24 | const [createBookAndAuthors, { error: mutationError }] = useMutation( 25 | CreateBookAndAuthors, 26 | { 27 | onCompleted: ({ createBookAndAuthors: { id } }) => { 28 | history.push(`/book/${id}`); 29 | } 30 | } 31 | ); 32 | 33 | const handleSubmitBook = async event => { 34 | event.preventDefault(); 35 | setTitleError(null); 36 | 37 | if (!title) { 38 | setTitleError("This field is required"); 39 | return; 40 | } 41 | 42 | createBookAndAuthors({ 43 | variables: { 44 | input: { 45 | authorNames: authors ? authors.split(/\s*,\s*/) : [], 46 | title, 47 | ...(cover && { cover }), 48 | ...(genre && { genre }), 49 | ...(summary && { summary }) 50 | } 51 | } 52 | }).catch(err => { 53 | console.error(err); 54 | }); 55 | }; 56 | 57 | let content = null; 58 | 59 | if (viewer) { 60 | content = ( 61 |
62 |

Create a New Book

63 |
64 | { 72 | setTitle(event.target.value); 73 | }} 74 | placeholder="Enter the book's title" 75 | value={title} 76 | /> 77 | { 84 | setCover(event.target.value); 85 | }} 86 | placeholder="Provide an URL for the book's cover image" 87 | value={cover} 88 | /> 89 | { 96 | setAuthors(event.target.value); 97 | }} 98 | placeholder="Enter the author's full name" 99 | value={authors} 100 | /> 101 | { 108 | setSummary(event.target.value); 109 | }} 110 | placeholder="Provide a short summary of the book's content" 111 | value={summary} 112 | /> 113 | { 93 | setRating(event.target.value); 94 | }} 95 | options={[ 96 | { text: "1", value: "1" }, 97 | { text: "2", value: "2" }, 98 | { text: "3", value: "3" }, 99 | { text: "4", value: "4" }, 100 | { text: "5", value: "5" } 101 | ]} 102 | value={rating} 103 | /> 104 | { 111 | setText(event.target.value); 112 | }} 113 | placeholder="Write a brief review of the book" 114 | value={text} 115 | /> 116 |
119 | ); 120 | } else if (error || viewerError) { 121 | content = ; 122 | } 123 | 124 | return {content}; 125 | } 126 | 127 | export default ReviewBook; 128 | -------------------------------------------------------------------------------- /client/src/pages/Search/index.js: -------------------------------------------------------------------------------- 1 | import { useLocation } from "react-router-dom"; 2 | import { useQuery } from "@apollo/client"; 3 | import queryString from "query-string"; 4 | 5 | import { SearchBooks } from "../../graphql/queries"; 6 | import BookGrid from "../../components/BookGrid"; 7 | import Loader from "../../components/Loader"; 8 | import MainLayout from "../../components/MainLayout"; 9 | import PageNotice from "../../components/PageNotice"; 10 | import SearchBooksForm from "../../components/SearchBooksForm"; 11 | 12 | function Search() { 13 | const location = useLocation(); 14 | const { q } = queryString.parse(location.search); 15 | 16 | const { data, error, loading } = useQuery(SearchBooks, { 17 | variables: { query: q } 18 | }); 19 | 20 | let content = null; 21 | 22 | if (loading) { 23 | content = ; 24 | } else if (data?.searchBooks) { 25 | const parsedBooks = data.searchBooks.reduce((acc, curr) => { 26 | if (curr.__typename === "Author") { 27 | return acc.concat(curr.books); 28 | } 29 | return acc.concat(curr); 30 | }, []); 31 | 32 | content = ( 33 |
34 | 35 | {parsedBooks.length ? ( 36 | 37 | ) : ( 38 |

39 | No books found! Please search again. 40 |

41 | )} 42 |
43 | ); 44 | } else if (error) { 45 | content = ( 46 | 47 | ); 48 | } 49 | 50 | return {content}; 51 | } 52 | 53 | export default Search; 54 | -------------------------------------------------------------------------------- /client/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from "history"; 2 | import { Switch } from "react-router"; 3 | 4 | import Book from "../pages/Book"; 5 | import Index from "../pages/Index"; 6 | import Home from "../pages/Home"; 7 | import Login from "../pages/Login"; 8 | import NewBook from "../pages/NewBook"; 9 | import PrivateRoute from "../components/PrivateRoute"; 10 | import PublicRoute from "../components/PublicRoute"; 11 | import ReviewBook from "../pages/ReviewBook"; 12 | import Search from "../pages/Search"; 13 | 14 | export const history = createBrowserHistory(); 15 | 16 | export function Routes() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 29 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /client/src/setupProxy.js: -------------------------------------------------------------------------------- 1 | const { createProxyMiddleware } = require("http-proxy-middleware"); 2 | 3 | const target = "http://localhost:4000"; 4 | 5 | module.exports = function (app) { 6 | app.use("/graphql", createProxyMiddleware("/graphql", { target, ws: true })); 7 | }; 8 | -------------------------------------------------------------------------------- /client/src/utils/updateQueries.js: -------------------------------------------------------------------------------- 1 | import { GetBook } from "../graphql/queries"; 2 | 3 | export function updateAddNewReviewToList(previousResult, subscriptionData) { 4 | if (!subscriptionData.data) { 5 | return previousResult; 6 | } 7 | const newReview = subscriptionData.data.reviewAdded; 8 | 9 | return { 10 | book: { 11 | ...previousResult.book, 12 | reviews: { 13 | __typename: previousResult.book.reviews.__typename, 14 | results: [newReview, ...previousResult.book.reviews.results], 15 | pageInfo: previousResult.book.reviews.pageInfo 16 | } 17 | } 18 | }; 19 | } 20 | 21 | export function updateViewerHasInLibrary(cache, id) { 22 | const { book } = cache.readQuery({ 23 | query: GetBook, 24 | variables: { id } 25 | }); 26 | 27 | cache.writeQuery({ 28 | query: GetBook, 29 | data: { 30 | book: { 31 | ...book, 32 | viewerHasInLibrary: !book.viewerHasInLibrary 33 | } 34 | } 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: [], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {}, 6 | fontFamily: { 7 | sans: 8 | "-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif", 9 | mono: "source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace" 10 | } 11 | }, 12 | variants: { 13 | extend: {} 14 | }, 15 | plugins: [] 16 | }; 17 | -------------------------------------------------------------------------------- /server/.env.sample: -------------------------------------------------------------------------------- 1 | GRAPHQL_API_PORT=4000 2 | JWT_SECRET=your_random_secret_here 3 | NODE_ENV=development 4 | REST_API_BASE_URL=http://localhost:5000 -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "server": "concurrently -k npm:server:*", 8 | "server:rest": "json-server -w db.json -p 5000 -r routes.json -q", 9 | "server:graphql": "nodemon --ignore db.json -r dotenv/config ./src/index.js" 10 | }, 11 | "type": "module", 12 | "keywords": [], 13 | "author": "", 14 | "license": "ISC", 15 | "dependencies": { 16 | "apollo-datasource-rest": "^0.10.0", 17 | "apollo-server": "^2.21.0", 18 | "apollo-server-express": "^2.21.0", 19 | "concurrently": "^5.3.0", 20 | "cookie": "^0.4.1", 21 | "cors": "^2.8.5", 22 | "dotenv": "^8.2.0", 23 | "express": "^4.17.1", 24 | "express-jwt": "^6.0.0", 25 | "graphql": "^15.5.0", 26 | "graphql-middleware": "^6.0.4", 27 | "graphql-shield": "^7.5.0", 28 | "graphql-subscriptions": "^1.2.1", 29 | "graphql-ws": "^4.3.2", 30 | "json-server": "^0.16.3", 31 | "jsonwebtoken": "^8.5.1", 32 | "node-fetch": "^2.6.1", 33 | "nodemon": "^2.0.7", 34 | "parse-link-header": "^1.0.1", 35 | "validator": "^13.5.2", 36 | "ws": "^7.4.4" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "/authors/:authorId/books": "/bookAuthors?authorId=:authorId&_expand=book", 3 | "/books/:bookId/authors": "/bookAuthors?bookId=:bookId&_expand=author", 4 | "/users/:userId/books": "/userBooks?userId=:userId&_expand=book" 5 | } 6 | -------------------------------------------------------------------------------- /server/src/graphql/dataSources/JsonServerApi.js: -------------------------------------------------------------------------------- 1 | import { 2 | AuthenticationError, 3 | ForbiddenError, 4 | UserInputError 5 | } from "apollo-server-express"; 6 | import { RESTDataSource } from "apollo-datasource-rest"; 7 | import jwt from "jsonwebtoken"; 8 | import parseLinkHeader from "parse-link-header"; 9 | import validator from "validator"; 10 | 11 | import { hashPassword, verifyPassword } from "../../utils/passwords.js"; 12 | 13 | class JsonServerApi extends RESTDataSource { 14 | baseURL = process.env.REST_API_BASE_URL; 15 | 16 | async didReceiveResponse(response) { 17 | if (response.ok) { 18 | this.linkHeader = response.headers.get("Link"); 19 | this.totalCountHeader = response.headers.get("X-Total-Count"); 20 | return this.parseBody(response); 21 | } else { 22 | throw await this.errorFromResponse(response); 23 | } 24 | } 25 | 26 | // UTILS 27 | 28 | // This method is replaced with a custom directive 29 | // async checkUniqueUserData(email, username) { 30 | // const res = await Promise.all([ 31 | // this.get(`/users?email=${email}`), 32 | // this.get(`/users?username=${username}`) 33 | // ]); 34 | // const [existingEmail, existingUsername] = res; 35 | 36 | // if (existingEmail.length) { 37 | // throw new UserInputError("Email is already in use"); 38 | // } else if (existingUsername.length) { 39 | // throw new UserInputError("Username already in use"); 40 | // } 41 | // } 42 | 43 | parsePageInfo({ limit, page }) { 44 | if (this.totalCountHeader) { 45 | let hasNextPage, hasPrevPage; 46 | 47 | if (this.linkHeader) { 48 | const { next, prev } = parseLinkHeader(this.linkHeader); 49 | hasNextPage = !!next; 50 | hasPrevPage = !!prev; 51 | } 52 | 53 | return { 54 | hasNextPage: hasNextPage || false, 55 | hasPrevPage: hasPrevPage || false, 56 | page: page || 1, 57 | perPage: limit, 58 | totalCount: this.totalCountHeader 59 | }; 60 | } 61 | 62 | return null; 63 | } 64 | 65 | parseParams({ limit, orderBy, page, ...rest }) { 66 | if (limit && limit > 100) { 67 | throw new UserInputError("Maximum of 100 results per page"); 68 | } 69 | 70 | const paginationParams = []; 71 | paginationParams.push(`_limit=${limit}`, `_page=${page || "1"}`); 72 | 73 | const [sort, order] = orderBy ? orderBy.split("_") : []; 74 | const otherParams = Object.keys(rest).map(key => `${key}=${rest[key]}`); 75 | const queryString = [ 76 | ...(sort ? [`_sort=${sort}`] : []), 77 | ...(order ? [`_order=${order}`] : []), 78 | ...paginationParams, 79 | ...otherParams 80 | ].join("&"); 81 | 82 | return queryString ? `?${queryString}` : ""; 83 | } 84 | 85 | // READ 86 | 87 | async checkViewerHasInLibrary(id, bookId) { 88 | const response = await this.get(`/userBooks?userId=${id}&bookId=${bookId}`); 89 | return !!response.length; 90 | } 91 | 92 | async checkViewerHasReviewed(id, bookId) { 93 | const response = await this.get(`/reviews?bookId=${bookId}&userId=${id}`); 94 | return !!response.length; 95 | } 96 | 97 | getAuthorById(id) { 98 | return this.get(`/authors/${id}`).catch( 99 | err => err.message === "404: Not Found" && null 100 | ); 101 | } 102 | 103 | async getAuthorBooks(authorId) { 104 | const items = await this.get(`/authors/${authorId}/books`); 105 | return items.map(item => item.book); 106 | } 107 | 108 | async getAuthors({ limit, page, orderBy = "name_asc" }) { 109 | const queryString = this.parseParams({ 110 | ...(limit && { limit }), 111 | ...(page && { page }), 112 | orderBy 113 | }); 114 | const authors = await this.get(`/authors${queryString}`); 115 | const pageInfo = this.parsePageInfo({ limit, page }); 116 | 117 | return { results: authors, pageInfo }; 118 | } 119 | 120 | getBookById(id) { 121 | return this.get(`/books/${id}`).catch( 122 | err => err.message === "404: Not Found" && null 123 | ); 124 | } 125 | 126 | async getBookAuthors(bookId) { 127 | const items = await this.get(`/books/${bookId}/authors`); 128 | return items.map(item => item.author); 129 | } 130 | 131 | async getBookReviews(bookId, { limit, page, orderBy = "createdAt_desc" }) { 132 | const queryString = this.parseParams({ 133 | ...(limit && { limit }), 134 | ...(page && { page }), 135 | bookId, 136 | orderBy 137 | }); 138 | const reviews = await this.get(`/reviews${queryString}`); 139 | const pageInfo = this.parsePageInfo({ limit, page }); 140 | 141 | return { results: reviews, pageInfo }; 142 | } 143 | 144 | async getBooks({ limit, page, orderBy = "title_asc" }) { 145 | const queryString = this.parseParams({ 146 | ...(limit && { limit }), 147 | ...(page && { page }), 148 | orderBy 149 | }); 150 | const books = await this.get(`/books${queryString}`); 151 | const pageInfo = this.parsePageInfo({ limit, page }); 152 | 153 | return { results: books, pageInfo }; 154 | } 155 | 156 | getReviewById(id) { 157 | return this.get(`/reviews/${id}`).catch( 158 | err => err.message === "404: Not Found" && null 159 | ); 160 | } 161 | 162 | async getUser(username) { 163 | const [user] = await this.get(`/users?username=${username}`); 164 | return user; 165 | } 166 | 167 | getUserById(id) { 168 | return this.get(`/users/${id}`).catch( 169 | err => err.message === "404: Not Found" && null 170 | ); 171 | } 172 | 173 | async getUserLibrary(userId, { limit, page, orderBy = "createdAt_desc" }) { 174 | const queryString = this.parseParams({ 175 | _expand: "book", 176 | ...(limit && { limit }), 177 | ...(page && { page }), 178 | orderBy, 179 | userId 180 | }); 181 | const items = await this.get(`/userBooks${queryString}`); 182 | const books = items.map(item => item.book); 183 | const pageInfo = this.parsePageInfo({ limit, page }); 184 | 185 | return { results: books, pageInfo }; 186 | } 187 | 188 | async getUserReviews(userId, { limit, page, orderBy = "createdAt_desc" }) { 189 | const queryString = this.parseParams({ 190 | ...(limit && { limit }), 191 | ...(page && { page }), 192 | orderBy, 193 | userId 194 | }); 195 | const reviews = await this.get(`/reviews${queryString}`); 196 | const pageInfo = this.parsePageInfo({ limit, page }); 197 | 198 | return { results: reviews, pageInfo }; 199 | } 200 | 201 | async searchBooks({ exact, query, orderBy = "RESULT_ASC" }) { 202 | const bookQueryString = this.parseParams({ 203 | ...(exact ? { title: query } : { q: query }), 204 | limit: 50 205 | }); 206 | const authorQueryString = this.parseParams({ 207 | ...(exact ? { name: query } : { q: query }), 208 | limit: 50 209 | }); 210 | 211 | const authors = await this.get(`/authors${authorQueryString}`); 212 | const books = await this.get(`/books${bookQueryString}`); 213 | const results = [].concat(authors, books).sort((a, b) => { 214 | const aKey = a.hasOwnProperty("title") ? "title" : "name"; 215 | const bKey = b.hasOwnProperty("title") ? "title" : "name"; 216 | 217 | return orderBy === "RESULT_ASC" 218 | ? a[aKey].localeCompare(b[bKey]) 219 | : b[bKey].localeCompare(a[aKey]); 220 | }); 221 | 222 | return results; 223 | } 224 | 225 | async searchPeople({ exact, query, orderBy = "RESULT_ASC" }) { 226 | const queryString = this.parseParams({ 227 | ...(exact ? { name: query } : { q: query }), 228 | limit: 50 229 | }); 230 | const authors = await this.get(`/authors${queryString}`); 231 | const users = await this.get(`/users${queryString}`); 232 | const results = [] 233 | .concat(authors, users) 234 | .sort((a, b) => 235 | orderBy === "RESULT_ASC" 236 | ? a.name.localeCompare(b.name) 237 | : b.name.localeCompare(a.name) 238 | ); 239 | 240 | return results; 241 | } 242 | 243 | // CREATE 244 | 245 | createAuthor(name) { 246 | return this.post("/authors", { name }); 247 | } 248 | 249 | async createBook({ authorIds, cover, genre, summary, title }) { 250 | const book = await this.post("/books", { 251 | ...(cover && { cover }), 252 | ...(genre && { genre }), 253 | ...(summary && { summary }), 254 | title 255 | }); 256 | 257 | if (authorIds?.length) { 258 | await Promise.all( 259 | authorIds.map(authorId => 260 | this.post("/bookAuthors", { 261 | authorId: parseInt(authorId), 262 | bookId: book.id 263 | }) 264 | ) 265 | ); 266 | } 267 | 268 | return book; 269 | } 270 | 271 | async createBookAndAuthors({ authorNames, ...args }) { 272 | let authorIds = []; 273 | 274 | if (authorNames?.length) { 275 | const authorSearchResults = await Promise.all( 276 | authorNames.map(authorName => this.get(`/authors?name=${authorName}`)) 277 | ); 278 | const existingAuthors = authorSearchResults.flat(); 279 | const existingAuthorIds = existingAuthors.map(author => author.id); 280 | authorIds.push(...existingAuthorIds); 281 | 282 | const authorsToCreate = existingAuthors.length 283 | ? authorNames.filter( 284 | authorName => 285 | !existingAuthors.find(author => author.name === authorName) 286 | ) 287 | : authorNames; 288 | 289 | const newAuthorIds = []; 290 | if (authorsToCreate.length) { 291 | const newAuthors = await Promise.all( 292 | authorsToCreate.map(name => this.createAuthor(name)) 293 | ); 294 | newAuthors.forEach(newAuthor => { 295 | newAuthorIds.push(newAuthor.id); 296 | }); 297 | } 298 | authorIds.push(...newAuthorIds); 299 | } 300 | 301 | return this.createBook({ authorIds, ...args }); 302 | } 303 | 304 | async createReview({ bookId, rating, reviewerId, text }) { 305 | const existingReview = await this.get( 306 | `/reviews?bookId=${bookId}&userId=${reviewerId}` 307 | ); 308 | 309 | if (existingReview.length) { 310 | throw new ForbiddenError("Users can only submit one review per book"); 311 | } 312 | 313 | return this.post("/reviews", { 314 | ...(text && { text }), 315 | bookId: parseInt(bookId), 316 | createdAt: new Date().toISOString(), 317 | rating, 318 | userId: parseInt(reviewerId) 319 | }); 320 | } 321 | 322 | async signUp({ email, name, password, username }) { 323 | if (!validator.isStrongPassword(password)) { 324 | throw new UserInputError( 325 | "Password must be a minimum of 8 characters in length and contain 1 lowercase letter, 1 uppercase letter, 1 number, and 1 special character" 326 | ); 327 | } 328 | 329 | const passwordHash = await hashPassword(password); 330 | const user = await this.post("/users", { 331 | email, 332 | name, 333 | password: passwordHash, 334 | username 335 | }); 336 | const token = jwt.sign({ username }, process.env.JWT_SECRET, { 337 | algorithm: "HS256", 338 | subject: user.id.toString(), 339 | expiresIn: "1d" 340 | }); 341 | 342 | return { token, viewer: user }; 343 | } 344 | 345 | // UPDATE 346 | 347 | async addBooksToLibrary({ bookIds, userId }) { 348 | const response = await Promise.all( 349 | bookIds.map(bookId => 350 | this.get(`/userBooks/?userId=${userId}&bookId=${bookId}`) 351 | ) 352 | ); 353 | const existingUserBooks = response.flat(); 354 | const newBookIds = bookIds.filter( 355 | bookId => !existingUserBooks.find(book => book.id === parseInt(bookId)) 356 | ); 357 | 358 | await Promise.all( 359 | newBookIds.map(bookId => 360 | this.post("/userBooks", { 361 | bookId: parseInt(bookId), 362 | createdAt: new Date().toISOString(), 363 | userId: parseInt(userId) 364 | }) 365 | ) 366 | ); 367 | 368 | return this.get(`/users/${userId}`); 369 | } 370 | 371 | async removeBooksFromLibrary({ bookIds, userId }) { 372 | const response = await Promise.all( 373 | bookIds.map(bookId => 374 | this.get(`/userBooks/?userId=${userId}&bookId=${bookId}`) 375 | ) 376 | ); 377 | const existingUserBooks = response.flat(); 378 | 379 | await Promise.all( 380 | existingUserBooks.map(({ id }) => this.delete(`/userBooks/${id}`)) 381 | ); 382 | 383 | return this.get(`/users/${userId}`); 384 | } 385 | 386 | updateReview({ id, rating, text }) { 387 | return this.patch(`reviews/${id}`, { 388 | rating, 389 | ...(text && { text }) 390 | }); 391 | } 392 | 393 | // DELETE 394 | 395 | async deleteReview(id) { 396 | await this.delete(`/reviews/${id}`); 397 | return id; 398 | } 399 | 400 | // LOGIN 401 | 402 | async login({ password, username }) { 403 | const user = await this.getUser(username); 404 | 405 | if (!user) { 406 | throw new AuthenticationError("User with that username does not exist"); 407 | } 408 | 409 | const isValidPassword = await verifyPassword(password, user.password); 410 | 411 | if (!isValidPassword) { 412 | throw new AuthenticationError("Username or password is incorrect"); 413 | } 414 | 415 | const token = jwt.sign({ username }, process.env.JWT_SECRET, { 416 | algorithm: "HS256", 417 | subject: user.id.toString(), 418 | expiresIn: "1d" 419 | }); 420 | 421 | return { token, viewer: user }; 422 | } 423 | } 424 | 425 | export default JsonServerApi; 426 | -------------------------------------------------------------------------------- /server/src/graphql/directives/UniqueDirective.js: -------------------------------------------------------------------------------- 1 | import { defaultFieldResolver } from "graphql"; 2 | import { SchemaDirectiveVisitor, UserInputError } from "apollo-server-express"; 3 | import fetch from "node-fetch"; 4 | 5 | const baseUrl = process.env.REST_API_BASE_URL; 6 | 7 | class UniqueDirective extends SchemaDirectiveVisitor { 8 | getMutations(predicate = null) { 9 | if (!this._mutations) { 10 | this._mutations = Object.values( 11 | this.schema.getMutationType().getFields() 12 | ); 13 | } 14 | 15 | if (!predicate) { 16 | return this._mutations || []; 17 | } 18 | 19 | return this._mutations.filter(predicate); 20 | } 21 | 22 | getMutationArgumentValue(fieldName, args) { 23 | const argTuples = Object.entries(args); 24 | 25 | for (let i = 0; i < argTuples.length; i++) { 26 | const [key, value] = argTuples[i]; 27 | 28 | if (value !== null && typeof value === "object") { 29 | return this.getMutationArgumentValue(fieldName, value); 30 | } else if (key === fieldName) { 31 | return value; 32 | } 33 | } 34 | 35 | return null; 36 | } 37 | 38 | visitInputFieldDefinition(field, { objectType }) { 39 | const { path, key } = this.args; 40 | const fieldName = key ? key : field.name; 41 | 42 | const mutationsForInput = this.getMutations(({ args = [] }) => { 43 | return args.find(arg => arg?.type?.ofType === objectType); 44 | }); 45 | 46 | mutationsForInput.forEach(mutation => { 47 | const { resolve = defaultFieldResolver } = mutation; 48 | mutation.resolve = async (...args) => { 49 | const uniqueValue = this.getMutationArgumentValue(fieldName, args[1]); 50 | 51 | if (uniqueValue) { 52 | const response = await fetch( 53 | `${baseUrl}/${path}?${fieldName}=${uniqueValue}` 54 | ); 55 | const results = await response.json(); 56 | 57 | if (results.length) { 58 | throw new UserInputError( 59 | `Value for ${fieldName} is already in use` 60 | ); 61 | } 62 | } 63 | 64 | return await resolve.apply(this, args); 65 | }; 66 | }); 67 | } 68 | } 69 | 70 | export default UniqueDirective; 71 | -------------------------------------------------------------------------------- /server/src/graphql/permissions.js: -------------------------------------------------------------------------------- 1 | import { and, rule, shield } from "graphql-shield"; 2 | 3 | const isAuthenticated = rule()((parent, args, { user }, info) => { 4 | return user !== null; 5 | }); 6 | 7 | const isUpdatingOwnLibrary = rule()( 8 | (root, { input: { userId } }, { user }, info) => { 9 | return user?.sub === userId; 10 | } 11 | ); 12 | 13 | const isEditingOwnReview = rule()( 14 | async (root, args, { dataSources, user }, info) => { 15 | const id = args.input ? args.input.id : args.id; 16 | const review = await dataSources.jsonServerApi.getReviewById(id); 17 | return user.sub === review.userId.toString(); 18 | } 19 | ); 20 | 21 | const permissions = shield( 22 | { 23 | Query: { 24 | searchPeople: isAuthenticated, 25 | user: isAuthenticated 26 | }, 27 | Mutation: { 28 | addBooksToLibrary: and(isAuthenticated, isUpdatingOwnLibrary), 29 | createAuthor: isAuthenticated, 30 | createBook: isAuthenticated, 31 | createReview: isAuthenticated, 32 | deleteReview: and(isAuthenticated, isEditingOwnReview), 33 | removeBooksFromLibrary: and(isAuthenticated, isUpdatingOwnLibrary), 34 | updateReview: and(isAuthenticated, isEditingOwnReview) 35 | } 36 | }, 37 | { debug: process.env.NODE_ENV === "development" } 38 | ); 39 | 40 | export default permissions; 41 | -------------------------------------------------------------------------------- /server/src/graphql/plugins/cookieHeaderPlugin.js: -------------------------------------------------------------------------------- 1 | import cookie from "cookie"; 2 | 3 | const cookieHeaderPlugin = { 4 | requestDidStart() { 5 | return { 6 | willSendResponse({ operation, response }) { 7 | if (operation?.operation === "mutation") { 8 | const authMutation = operation.selectionSet.selections.find( 9 | selection => 10 | selection.name.value === "login" || 11 | selection.name.value === "logout" || 12 | selection.name.value === "signUp" 13 | ); 14 | 15 | if (!authMutation) { 16 | return; 17 | } 18 | 19 | const fieldName = authMutation.name.value; 20 | 21 | if (fieldName === "logout") { 22 | const cookieString = cookie.serialize("token", "", { 23 | httpOnly: true, 24 | expires: new Date(1) 25 | }); 26 | response.http.headers.set("Set-Cookie", cookieString); 27 | } else { 28 | if (response.data?.[fieldName].token) { 29 | const cookieString = cookie.serialize( 30 | "token", 31 | response.data[fieldName].token, 32 | { httpOnly: true, maxAge: 86400 } 33 | ); 34 | response.http.headers.set("Set-Cookie", cookieString); 35 | } 36 | } 37 | } 38 | } 39 | }; 40 | } 41 | }; 42 | 43 | export default cookieHeaderPlugin; 44 | -------------------------------------------------------------------------------- /server/src/graphql/resolvers.js: -------------------------------------------------------------------------------- 1 | import { PubSub, withFilter } from "graphql-subscriptions"; 2 | 3 | import DateTimeType from "./scalars/DateTimeType.js"; 4 | import RatingType from "./scalars/RatingType.js"; 5 | 6 | const pubsub = new PubSub(); 7 | const REVIEW_ADDED = "REVIEW_ADDED"; 8 | 9 | const resolvers = { 10 | // SCALARS 11 | 12 | DateTime: DateTimeType, 13 | 14 | Rating: RatingType, 15 | 16 | // ENUMS 17 | 18 | AuthorOrderBy: { 19 | NAME_ASC: "name_asc", 20 | NAME_DESC: "name_desc" 21 | }, 22 | BookOrderBy: { 23 | TITLE_ASC: "title_asc", 24 | TITLE_DESC: "title_desc" 25 | }, 26 | LibraryOrderBy: { 27 | ADDED_ON_ASC: "createdAt_asc", 28 | ADDED_ON_DESC: "createdAt_desc" 29 | }, 30 | ReviewOrderBy: { 31 | REVIEWED_ON_ASC: "createdAt_asc", 32 | REVIEWED_ON_DESC: "createdAt_desc" 33 | }, 34 | 35 | // INTERFACES 36 | 37 | Person: { 38 | __resolveType(obj, context, info) { 39 | if (obj.username) { 40 | return "User"; 41 | } else { 42 | return "Author"; 43 | } 44 | } 45 | }, 46 | 47 | // UNIONS 48 | 49 | BookResult: { 50 | __resolveType(obj, context, info) { 51 | if (obj.title) { 52 | return "Book"; 53 | } else { 54 | return "Author"; 55 | } 56 | } 57 | }, 58 | 59 | // OBJECTS 60 | 61 | Author: { 62 | books(author, args, { dataSources }, info) { 63 | return dataSources.jsonServerApi.getAuthorBooks(author.id); 64 | } 65 | }, 66 | Book: { 67 | authors(book, args, { dataSources }, info) { 68 | return dataSources.jsonServerApi.getBookAuthors(book.id); 69 | }, 70 | reviews(book, args, { dataSources }, info) { 71 | return dataSources.jsonServerApi.getBookReviews(book.id, args); 72 | }, 73 | viewerHasInLibrary(book, args, { dataSources, user }, info) { 74 | return user?.sub 75 | ? dataSources.jsonServerApi.checkViewerHasInLibrary(user.sub, book.id) 76 | : null; 77 | }, 78 | viewerHasReviewed(book, args, { dataSources, user }, info) { 79 | return user?.sub 80 | ? dataSources.jsonServerApi.checkViewerHasReviewed(user.sub, book.id) 81 | : null; 82 | } 83 | }, 84 | Review: { 85 | book(review, args, { dataSources }, info) { 86 | return dataSources.jsonServerApi.getBookById(review.bookId); 87 | }, 88 | reviewedOn(review, args, { dataSources }, info) { 89 | return review.createdAt; 90 | }, 91 | reviewer(review, args, { dataSources }, info) { 92 | return dataSources.jsonServerApi.getUserById(review.userId); 93 | } 94 | }, 95 | User: { 96 | library(user, args, { dataSources }, info) { 97 | return dataSources.jsonServerApi.getUserLibrary(user.id, args); 98 | }, 99 | reviews(user, args, { dataSources }, info) { 100 | return dataSources.jsonServerApi.getUserReviews(user.id, args); 101 | } 102 | }, 103 | 104 | // ROOT 105 | 106 | Query: { 107 | author(root, { id }, { dataSources }, info) { 108 | return dataSources.jsonServerApi.getAuthorById(id); 109 | }, 110 | authors(root, args, { dataSources }, info) { 111 | return dataSources.jsonServerApi.getAuthors(args); 112 | }, 113 | book(root, { id }, { dataSources }, info) { 114 | return dataSources.jsonServerApi.getBookById(id); 115 | }, 116 | books(root, args, { dataSources }, info) { 117 | return dataSources.jsonServerApi.getBooks(args); 118 | }, 119 | review(root, { id }, { dataSources }, info) { 120 | return dataSources.jsonServerApi.getReviewById(id); 121 | }, 122 | searchBooks(root, args, { dataSources }, info) { 123 | return dataSources.jsonServerApi.searchBooks(args); 124 | }, 125 | searchPeople(root, args, { dataSources }, info) { 126 | return dataSources.jsonServerApi.searchPeople(args); 127 | }, 128 | user(root, { username }, { dataSources }, info) { 129 | return dataSources.jsonServerApi.getUser(username); 130 | }, 131 | viewer(root, args, { dataSources, user }, info) { 132 | if (user?.username) { 133 | return dataSources.jsonServerApi.getUser(user.username); 134 | } 135 | return null; 136 | } 137 | }, 138 | Mutation: { 139 | addBooksToLibrary(root, { input }, { dataSources }, info) { 140 | return dataSources.jsonServerApi.addBooksToLibrary(input); 141 | }, 142 | createAuthor(root, { name }, { dataSources }, info) { 143 | return dataSources.jsonServerApi.createAuthor(name); 144 | }, 145 | createBook(root, { input }, { dataSources }, info) { 146 | return dataSources.jsonServerApi.createBook(input); 147 | }, 148 | createBookAndAuthors(root, { input }, { dataSources }, info) { 149 | return dataSources.jsonServerApi.createBookAndAuthors(input); 150 | }, 151 | async createReview(root, { input }, { dataSources }, info) { 152 | const review = await dataSources.jsonServerApi.createReview(input); 153 | pubsub.publish(REVIEW_ADDED, { reviewAdded: review }); 154 | return review; 155 | }, 156 | deleteReview(root, { id }, { dataSources }, info) { 157 | return dataSources.jsonServerApi.deleteReview(id); 158 | }, 159 | login(root, args, { dataSources }, info) { 160 | return dataSources.jsonServerApi.login(args); 161 | }, 162 | logout(root, args, context, info) { 163 | return true; 164 | }, 165 | removeBooksFromLibrary(root, { input }, { dataSources }, info) { 166 | return dataSources.jsonServerApi.removeBooksFromLibrary(input); 167 | }, 168 | signUp(root, { input }, { dataSources }, info) { 169 | return dataSources.jsonServerApi.signUp(input); 170 | }, 171 | updateReview(root, { input }, { dataSources }, info) { 172 | return dataSources.jsonServerApi.updateReview(input); 173 | } 174 | }, 175 | Subscription: { 176 | reviewAdded: { 177 | subscribe: withFilter( 178 | (root, args, context, info) => { 179 | return pubsub.asyncIterator([REVIEW_ADDED]); 180 | }, 181 | (payload, variables, context, info) => { 182 | return payload.reviewAdded.bookId === parseInt(variables.bookId); 183 | } 184 | ) 185 | } 186 | } 187 | }; 188 | 189 | export default resolvers; 190 | -------------------------------------------------------------------------------- /server/src/graphql/scalars/DateTimeType.js: -------------------------------------------------------------------------------- 1 | import { ApolloError } from "apollo-server-express"; 2 | import { GraphQLScalarType } from "graphql"; 3 | import validator from "validator"; 4 | 5 | const DateTimeType = new GraphQLScalarType({ 6 | name: "DateTime", 7 | description: "An ISO 8601-encoded UTC date string.", 8 | // value from the client 9 | parseValue: value => { 10 | if (validator.isISO8601(value)) { 11 | return value; 12 | } 13 | throw new ApolloError("DateTime must be a valid ISO 8601 date string"); 14 | }, 15 | // value sent to the client 16 | serialize: value => { 17 | if (validator.isISO8601(value)) { 18 | return value; 19 | } 20 | throw new ApolloError("DateTime must be a valid ISO 8601 date string"); 21 | }, 22 | // ast value is always in string format 23 | parseLiteral: ast => { 24 | if (validator.isISO8601(ast.value)) { 25 | return ast.value; 26 | } 27 | throw new ApolloError("DateTime must be a valid ISO 8601 date string"); 28 | } 29 | }); 30 | 31 | export default DateTimeType; 32 | -------------------------------------------------------------------------------- /server/src/graphql/scalars/RatingType.js: -------------------------------------------------------------------------------- 1 | import { ApolloError } from "apollo-server-express"; 2 | import { GraphQLScalarType } from "graphql"; 3 | import { Kind } from "graphql"; 4 | 5 | function isValidRating(value) { 6 | return Number.isInteger(value) && value >= 1 && value <= 5; 7 | } 8 | 9 | const RatingType = new GraphQLScalarType({ 10 | name: "Rating", 11 | description: "An integer representing a user rating from 1 and 5, inclusive.", 12 | // value from the client 13 | parseValue: value => { 14 | if (isValidRating(value)) { 15 | return value; 16 | } 17 | throw new ApolloError("Rating must be an integer from 1 and 5"); 18 | }, 19 | // value sent to the client 20 | serialize: value => { 21 | // value is a string here... 22 | if (isValidRating(parseInt(value))) { 23 | return value; 24 | } 25 | throw new ApolloError("Rating must be an integer from 1 and 5"); 26 | }, 27 | // ast value is always in string format 28 | parseLiteral: ast => { 29 | const intValue = parseInt(ast.value); 30 | if (ast.kind === Kind.INT && isValidRating(intValue)) { 31 | return intValue; 32 | } 33 | throw new ApolloError("Rating must be and integer from 1 and 5"); 34 | } 35 | }); 36 | 37 | export default RatingType; 38 | -------------------------------------------------------------------------------- /server/src/graphql/typeDefs.js: -------------------------------------------------------------------------------- 1 | import { gql } from "apollo-server-express"; 2 | 3 | const typeDefs = gql` 4 | # DIRECTIVES 5 | 6 | directive @unique( 7 | "The resource path name from the REST endpoint." 8 | path: String! 9 | """ 10 | The database key name upon which to force uniqueness. 11 | 12 | If not provided, then the GraphQL schema field name will be used. 13 | """ 14 | key: String 15 | ) on INPUT_FIELD_DEFINITION 16 | 17 | # SCALARS 18 | 19 | """ 20 | An ISO 8601-encoded UTC date string. 21 | """ 22 | scalar DateTime 23 | 24 | """ 25 | An integer-based rating from 1 (low) to 5 (high). 26 | """ 27 | scalar Rating 28 | 29 | # ENUMS 30 | 31 | """ 32 | Literary genres that classify books. 33 | """ 34 | enum Genre { 35 | ADVENTURE 36 | CHILDREN 37 | CLASSICS 38 | COMIC_GRAPHIC_NOVEL 39 | DETECTIVE_MYSTERY 40 | DYSTOPIA 41 | FANTASY 42 | HORROR 43 | HUMOR 44 | NON_FICTION 45 | SCIENCE_FICTION 46 | ROMANCE 47 | THRILLER 48 | WESTERN 49 | } 50 | 51 | """ 52 | Sorting options for authors. 53 | """ 54 | enum AuthorOrderBy { 55 | NAME_ASC 56 | NAME_DESC 57 | } 58 | 59 | """ 60 | Sorting options for books. 61 | """ 62 | enum BookOrderBy { 63 | TITLE_ASC 64 | TITLE_DESC 65 | } 66 | 67 | """ 68 | Sorting options for books in a user's library. 69 | """ 70 | enum LibraryOrderBy { 71 | ADDED_ON_ASC 72 | ADDED_ON_DESC 73 | } 74 | 75 | """ 76 | Sorting options for reviews. 77 | """ 78 | enum ReviewOrderBy { 79 | REVIEWED_ON_ASC 80 | REVIEWED_ON_DESC 81 | } 82 | 83 | """ 84 | Sorting options for search results. 85 | """ 86 | enum SearchOrderBy { 87 | RESULT_ASC 88 | RESULT_DESC 89 | } 90 | 91 | # INTERFACES 92 | 93 | """ 94 | Specifies fields shared by people (including authors and users). 95 | """ 96 | interface Person { 97 | "The unique ID of the person." 98 | id: ID! 99 | "The full name of the person." 100 | name: String! 101 | } 102 | 103 | # UNIONS 104 | 105 | """ 106 | Types that can be returned from book-related search results. 107 | """ 108 | union BookResult = Book | Author 109 | 110 | # OBJECTS 111 | 112 | """ 113 | An author is a person who wrote one or more books. 114 | """ 115 | type Author implements Person { 116 | "The unique ID of the author." 117 | id: ID! 118 | "Books that were authored or co-authored by this person." 119 | books: [Book] 120 | "The full name of the author." 121 | name: String! 122 | } 123 | 124 | """ 125 | A list of author results with pagination information. 126 | """ 127 | type Authors { 128 | "A list of author results." 129 | results: [Author] 130 | "Information to assist with pagination." 131 | pageInfo: PageInfo 132 | } 133 | 134 | """ 135 | A currently authenticated user and their valid JWT. 136 | """ 137 | type AuthPayload { 138 | "The logged-in user." 139 | viewer: User 140 | "A JWT issued at the time of the user's most recent authentication." 141 | token: String 142 | } 143 | 144 | """ 145 | A written work that can be attributed to one or more authors and can be reviewed by users. 146 | """ 147 | type Book { 148 | "The unique ID of the book." 149 | id: ID! 150 | "The author(s) who wrote the books" 151 | authors: [Author] 152 | "The URL of the book's cover image." 153 | cover: String 154 | "A literary genre to which the book can be assigned." 155 | genre: Genre 156 | """ 157 | User-submitted reviews of the book. 158 | 159 | Default sort order is REVIEWED_ON_DESC. 160 | """ 161 | reviews(limit: Int = 20, orderBy: ReviewOrderBy, page: Int): Reviews 162 | "A brief description of the book's content." 163 | summary: String 164 | "The title of the book." 165 | title: String! 166 | "A boolean indicating if the viewing user has added this book to their library." 167 | viewerHasInLibrary: Boolean 168 | "A boolean indicating if the viewing user has reviewed the book." 169 | viewerHasReviewed: Boolean 170 | } 171 | 172 | """ 173 | A list of book results with pagination information. 174 | """ 175 | type Books { 176 | "A list of book results." 177 | results: [Book] 178 | "Information to assist with pagination." 179 | pageInfo: PageInfo 180 | } 181 | 182 | """ 183 | Contains information about the current page of results. 184 | """ 185 | type PageInfo { 186 | "Whether there are items to retrieve on a subsequent page." 187 | hasNextPage: Boolean 188 | "Whether there are items to retrieve on a preceding page." 189 | hasPrevPage: Boolean 190 | "The current page number." 191 | page: Int 192 | "The number of items retrieved per page." 193 | perPage: Int 194 | "The total item count across all pages." 195 | totalCount: Int 196 | } 197 | 198 | """ 199 | A user-submitted assessment of a book that may include a numerical rating. 200 | """ 201 | type Review { 202 | "The unique ID of the review." 203 | id: ID! 204 | "The book to which the review applies." 205 | book: Book 206 | "The user's integer-based rating of the book (from 1 to 5)." 207 | rating: Rating! 208 | "The date and time the review was created." 209 | reviewedOn: DateTime! 210 | "The user who submitted the book review." 211 | reviewer: User! 212 | "The text-based content of the review." 213 | text: String 214 | } 215 | 216 | """ 217 | A list of review results with pagination information. 218 | """ 219 | type Reviews { 220 | "A list of review results." 221 | results: [Review] 222 | "Information to assist with pagination." 223 | pageInfo: PageInfo 224 | } 225 | 226 | """ 227 | A user account provides authentication and library details. 228 | """ 229 | type User implements Person { 230 | "The unique ID of the user." 231 | id: ID! 232 | "The email address of the user (must be unique)." 233 | email: String! 234 | """ 235 | A list of books the user has added to their library. 236 | 237 | Default sort order is ADDED_ON_DESC. 238 | """ 239 | library(limit: Int = 20, orderBy: LibraryOrderBy, page: Int): Books 240 | "The full name of the user." 241 | name: String! 242 | """ 243 | A list of book reviews created by the user. 244 | 245 | Default sort order is REVIEWED_ON_DESC. 246 | """ 247 | reviews(limit: Int = 20, orderBy: ReviewOrderBy, page: Int): Reviews 248 | "The user's chosen username (must be unique)." 249 | username: String! 250 | } 251 | 252 | # INPUTS 253 | 254 | """ 255 | Provides data to create a book. 256 | """ 257 | input CreateBookInput { 258 | "The IDs of the authors who wrote the book." 259 | authorIds: [ID] 260 | """ 261 | The URL of the book's cover image. Covers available via the Open Library Covers API: 262 | 263 | https://openlibrary.org/dev/docs/api/covers 264 | """ 265 | cover: String 266 | "A literary genre to which the book can be assigned." 267 | genre: Genre 268 | "A short summary of the book's content." 269 | summary: String 270 | "The title of the book." 271 | title: String! 272 | } 273 | 274 | """ 275 | Provides data to create a book and any associated authors. 276 | """ 277 | input CreateBookAndAuthorsInput { 278 | "The names of the authors who wrote the book. Non-existent authors will be created." 279 | authorNames: [String] 280 | """ 281 | The URL of the book's cover image. Covers available via the Open Library Covers API: 282 | 283 | https://openlibrary.org/dev/docs/api/covers 284 | """ 285 | cover: String 286 | "A literary genre to which the book can be assigned." 287 | genre: Genre 288 | "A short summary of the book's content." 289 | summary: String 290 | "The title of the book." 291 | title: String! 292 | } 293 | 294 | """ 295 | Provides data to create a review. 296 | """ 297 | input CreateReviewInput { 298 | "The unique ID of the book a user is reviewing." 299 | bookId: ID! 300 | "The user's integer-based rating of the book (from 1 to 5)." 301 | rating: Rating! 302 | "The ID of the user submitting the review." 303 | reviewerId: ID! 304 | "The text-based content of the review." 305 | text: String 306 | } 307 | 308 | """ 309 | Provides data to create a user. 310 | """ 311 | input SignUpInput { 312 | "The email address of the user (must be unique)." 313 | email: String! @unique(path: "users") 314 | "The full name of the user." 315 | name: String! 316 | """ 317 | The user's chosen password. 318 | 319 | It must be a minimum of 8 characters in length and contain 1 lowercase 320 | letter, 1 uppercase letter, 1 number, and 1 special character. 321 | """ 322 | password: String! 323 | "The user's chosen username (must be unique)." 324 | username: String! @unique(path: "users") 325 | } 326 | 327 | """ 328 | Provides data to add or remove books from a user's library. 329 | """ 330 | input UpdateLibraryBooksInput { 331 | "The IDs of the books to add or remove from the user's library." 332 | bookIds: [ID!]! 333 | "The ID of the user whose library should be updated." 334 | userId: ID! 335 | } 336 | 337 | """ 338 | Provides data to update a review. 339 | """ 340 | input UpdateReviewInput { 341 | "The unique ID of the review a user is updating." 342 | id: ID! 343 | "The user's integer-based rating of the book (from 1 to 5)." 344 | rating: Rating! 345 | "The text-based content of the review." 346 | text: String 347 | } 348 | 349 | # ROOT 350 | 351 | type Query { 352 | "Retrieves a single author by ID." 353 | author(id: ID!): Author 354 | """ 355 | Retrieves a list of authors with pagination information. 356 | 357 | Default sort order is NAME_ASC. 358 | """ 359 | authors(limit: Int = 20, orderBy: AuthorOrderBy, page: Int): Authors 360 | "Retrieves a single book by ID." 361 | book(id: ID!): Book 362 | """ 363 | Retrieves a list of books with pagination information. 364 | 365 | Default sort order is TITLE_ASC. 366 | """ 367 | books(limit: Int = 20, orderBy: BookOrderBy, page: Int): Books 368 | "Retrieves a single book by ID." 369 | review(id: ID!): Review 370 | """ 371 | Performs a search of book titles and author names. 372 | 373 | Default sort order is RESULTS_ASC. 374 | """ 375 | searchBooks( 376 | exact: Boolean = false 377 | orderBy: SearchOrderBy 378 | query: String! 379 | ): [BookResult] 380 | """ 381 | Performs a search of author and user names. 382 | 383 | Default sort order is RESULTS_ASC. 384 | """ 385 | searchPeople( 386 | exact: Boolean = false 387 | orderBy: SearchOrderBy 388 | query: String! 389 | ): [Person] 390 | "Retrieves a single user by username." 391 | user(username: String!): User 392 | "Retrieves the currently authenticated user." 393 | viewer: User 394 | } 395 | 396 | type Mutation { 397 | "Adds books to user's library." 398 | addBooksToLibrary(input: UpdateLibraryBooksInput!): User! 399 | "Creates a new author." 400 | createAuthor(name: String!): Author! 401 | "Creates a new book." 402 | createBook(input: CreateBookInput!): Book! 403 | "Creates a new book and any of its authors that do not yet exist." 404 | createBookAndAuthors(input: CreateBookAndAuthorsInput!): Book! 405 | "Creates a new review." 406 | createReview(input: CreateReviewInput!): Review! 407 | "Deletes a review." 408 | deleteReview(id: ID!): ID! 409 | "Authenticates an existing user." 410 | login(password: String!, username: String!): AuthPayload! 411 | "Logs an authenticated user out." 412 | logout: Boolean! 413 | "Remove books currently in a user's library." 414 | removeBooksFromLibrary(input: UpdateLibraryBooksInput!): User! 415 | "Creates a new user." 416 | signUp(input: SignUpInput!): AuthPayload! 417 | "Updates a review." 418 | updateReview(input: UpdateReviewInput!): Review! 419 | } 420 | 421 | type Subscription { 422 | reviewAdded(bookId: ID!): Review 423 | } 424 | `; 425 | 426 | export default typeDefs; 427 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | import http from "http"; 2 | 3 | import { ApolloServer, makeExecutableSchema } from "apollo-server-express"; 4 | import { applyMiddleware } from "graphql-middleware"; 5 | import { useServer } from "graphql-ws/lib/use/ws"; 6 | import cors from "cors"; 7 | import express from "express"; 8 | import expressJwt from "express-jwt"; 9 | import ws from "ws"; 10 | 11 | import { getToken, handleInvalidToken } from "./utils/tokens.js"; 12 | import cookieHeaderPlugin from "./graphql/plugins/cookieHeaderPlugin.js"; 13 | import JsonServerApi from "./graphql/dataSources/JsonServerApi.js"; 14 | import permissions from "./graphql/permissions.js"; 15 | import resolvers from "./graphql/resolvers.js"; 16 | import typeDefs from "./graphql/typeDefs.js"; 17 | import UniqueDirective from "./graphql/directives/UniqueDirective.js"; 18 | 19 | const port = process.env.GRAPHQL_API_PORT; 20 | const app = express(); 21 | 22 | if (process.env.NODE_ENV === "development") { 23 | app.use( 24 | cors({ 25 | origin: ["https://studio.apollographql.com", "http://localhost:3000"] 26 | }) 27 | ); 28 | } 29 | 30 | app.use( 31 | expressJwt({ 32 | secret: process.env.JWT_SECRET, 33 | algorithms: ["HS256"], 34 | credentialsRequired: false, 35 | getToken 36 | }), 37 | handleInvalidToken 38 | ); 39 | 40 | const schema = makeExecutableSchema({ 41 | typeDefs, 42 | resolvers, 43 | schemaDirectives: { 44 | unique: UniqueDirective 45 | } 46 | }); 47 | const schemaWithPermissions = applyMiddleware(schema, permissions); 48 | 49 | const server = new ApolloServer({ 50 | schema: schemaWithPermissions, 51 | dataSources: () => { 52 | return { 53 | jsonServerApi: new JsonServerApi() 54 | }; 55 | }, 56 | context: ({ req }) => { 57 | const user = req.user || null; 58 | return { user }; 59 | }, 60 | plugins: [cookieHeaderPlugin] 61 | }); 62 | 63 | server.applyMiddleware({ app, cors: false }); 64 | const httpServer = http.createServer(app); 65 | 66 | httpServer.listen(port, () => { 67 | const wsServer = new ws.Server({ server: httpServer, path: "/graphql" }); 68 | useServer( 69 | { 70 | schema: schemaWithPermissions, 71 | context: ctx => { 72 | const jsonServerApi = new JsonServerApi(); 73 | jsonServerApi.initialize({ context: ctx, cache: undefined }); 74 | return { dataSources: { jsonServerApi } }; 75 | } 76 | }, 77 | wsServer 78 | ); 79 | 80 | console.log( 81 | `🚀 Server ready at http://localhost:${port}${server.graphqlPath}` 82 | ); 83 | console.log( 84 | `🚀 Subscriptions ready at ws://localhost:${port}${wsServer.options.path}` 85 | ); 86 | }); 87 | -------------------------------------------------------------------------------- /server/src/utils/passwords.js: -------------------------------------------------------------------------------- 1 | import { promisify } from "util"; 2 | import { randomBytes, scrypt, timingSafeEqual } from "crypto"; 3 | 4 | const scryptAsync = promisify(scrypt); 5 | 6 | export async function hashPassword(password) { 7 | const salt = randomBytes(16).toString("hex"); 8 | const derivedKey = await scryptAsync(password, salt, 64); 9 | return salt + ":" + derivedKey.toString("hex"); 10 | } 11 | 12 | export async function verifyPassword(password, hash) { 13 | const [salt, key] = hash.split(":"); 14 | const keyBuffer = Buffer.from(key, "hex"); 15 | const derivedKey = await scryptAsync(password, salt, 64); 16 | return timingSafeEqual(keyBuffer, derivedKey); 17 | } 18 | -------------------------------------------------------------------------------- /server/src/utils/tokens.js: -------------------------------------------------------------------------------- 1 | export function getToken(req) { 2 | if ( 3 | req.headers.authorization && 4 | req.headers.authorization.split(" ")[0] === "Bearer" 5 | ) { 6 | return req.headers.authorization.split(" ")[1]; 7 | } else if (req.headers.cookie) { 8 | const tokenCookie = req.headers.cookie 9 | .split("; ") 10 | .find(cookie => cookie.includes("token")); 11 | 12 | return tokenCookie ? tokenCookie.split("=")[1] : null; 13 | } 14 | return null; 15 | } 16 | 17 | export function handleInvalidToken(err, req, res, next) { 18 | if (err.code === "invalid_token") { 19 | return next(); 20 | } 21 | return next(err); 22 | } 23 | --------------------------------------------------------------------------------