├── .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 |
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 |
58 | );
59 | }
60 |
61 | export default NavBar;
62 |
--------------------------------------------------------------------------------
/client/src/components/PageNotice/index.js:
--------------------------------------------------------------------------------
1 | function PageNotice({ text }) {
2 | return (
3 |
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 |
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 |
40 |
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 |
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 |

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 |
{
106 | const variables = {
107 | input: { bookIds: [id], userId: viewer.id }
108 | };
109 |
110 | if (viewerHasInLibrary) {
111 | removeBooksFromLibrary({ variables });
112 | } else {
113 | addBooksToLibrary({ variables });
114 | }
115 | }}
116 | text={
117 | viewerHasInLibrary ? "Remove from Library" : "Add to Library"
118 | }
119 | />
120 | )}
121 |
122 |
123 |
124 |
125 |
What Readers Say
126 | {viewer && !viewerHasReviewed && (
127 | {
129 | history.push(`/book/${id}/review/new`);
130 | }}
131 | primary
132 | text="Add a Review"
133 | />
134 | )}
135 |
136 | {reviews.results.length ? (
137 |
138 |
143 | {reviews.pageInfo.hasNextPage && (
144 | {
147 | fetchMore({
148 | variables: {
149 | reviewsLimit,
150 | reviewsPage: reviews.pageInfo.page + 1
151 | }
152 | });
153 | }}
154 | text="Load More"
155 | type="button"
156 | />
157 | )}
158 |
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 | {
45 | fetchMore({
46 | variables: { limit, page: page + 1 }
47 | });
48 | }}
49 | type="button"
50 | />
51 |
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 | {
37 | fetchMore({
38 | variables: { limit, page: page + 1 }
39 | });
40 | }}
41 | text="Load More"
42 | type="button"
43 | />
44 |
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 |
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 |
152 |
153 | );
154 | } else if (mutationError || viewerError) {
155 | content = ;
156 | }
157 |
158 | return {content};
159 | }
160 |
161 | export default NewBook;
162 |
--------------------------------------------------------------------------------
/client/src/pages/ReviewBook/index.js:
--------------------------------------------------------------------------------
1 | import { useHistory, useParams } from "react-router-dom";
2 | import { useMutation, useQuery } from "@apollo/client";
3 | import { useState } from "react";
4 |
5 | import { CreateReview, UpdateReview } from "../../graphql/mutations";
6 | import { GetReview } from "../../graphql/queries";
7 | import { useAuth } from "../../context/AuthContext";
8 | import Button from "../../components/Button";
9 | import Loader from "../../components/Loader";
10 | import MainLayout from "../../components/MainLayout";
11 | import PageNotice from "../../components/PageNotice";
12 | import Select from "../../components/Select";
13 | import TextInput from "../../components/TextInput";
14 |
15 | function ReviewBook() {
16 | const { bookId, reviewId } = useParams();
17 | const { viewer, error: viewerError } = useAuth();
18 | const history = useHistory();
19 |
20 | const [rating, setRating] = useState("5");
21 | const [text, setText] = useState("");
22 |
23 | const { loading, error } = useQuery(GetReview, {
24 | variables: { id: reviewId },
25 | skip: !reviewId || !viewer,
26 | onCompleted: data => {
27 | if (data?.review) {
28 | setRating(data.review.rating);
29 | setText(data.review.text);
30 | }
31 | }
32 | });
33 |
34 | const [createReview] = useMutation(CreateReview, {
35 | onCompleted: () => {
36 | history.push(`/book/${bookId}`);
37 | }
38 | });
39 | const [updateReview] = useMutation(UpdateReview, {
40 | onCompleted: () => {
41 | history.push(`/book/${bookId}`);
42 | }
43 | });
44 |
45 | const handleSubmit = event => {
46 | event.preventDefault();
47 |
48 | if (reviewId) {
49 | updateReview({
50 | variables: {
51 | input: {
52 | id: reviewId,
53 | rating: parseInt(rating),
54 | text
55 | }
56 | }
57 | }).catch(err => {
58 | console.error(err);
59 | });
60 | } else {
61 | createReview({
62 | variables: {
63 | input: {
64 | bookId,
65 | rating: parseInt(rating),
66 | reviewerId: viewer.id,
67 | text
68 | }
69 | }
70 | }).catch(err => {
71 | console.error(err);
72 | });
73 | }
74 | };
75 |
76 | let content = null;
77 |
78 | if (loading) {
79 | content = ;
80 | } else if (viewer) {
81 | content = (
82 |
83 |
84 | {reviewId ? "Update Review" : "Create a New Review"}
85 |
86 |
118 |
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 |
--------------------------------------------------------------------------------