├── .gitignore ├── README.md ├── client ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.js │ ├── components │ ├── Favorite.js │ ├── Login │ │ ├── index.js │ │ └── login.css │ └── MovieCard │ │ ├── index.js │ │ └── movieCard.css │ ├── index.css │ ├── index.js │ └── screens │ ├── MovieScreen │ ├── index.js │ └── movie.css │ └── MoviesScreen │ ├── index.js │ └── moviesScreen.css ├── docs └── api.md ├── package.json ├── server ├── nodemon.json ├── package.json └── src │ ├── apiClient.js │ ├── authDirective.js │ ├── axios.js │ ├── favoritesStore.js │ ├── main.js │ ├── schema │ ├── cast.graphql │ ├── genre.graphql │ ├── movie.graphql │ ├── mutation.graphql │ ├── query.graphql │ └── schema.graphql │ └── types │ ├── cast.js │ ├── index.js │ ├── movie.js │ ├── mutation.js │ ├── posterSize.js │ └── query.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real World GraphQL Workshop 2 | 3 | This is a workshop repo to teach you about GraphQL. 4 | 5 | 6 | 7 | **Table of Contents** 8 | 9 | - [Topics covered](#topics-covered) 10 | - [Branches & Tags](#branches--tags) 11 | - [System Requirements](#system-requirements) 12 | - [Setup](#setup) 13 | - [Running the app](#running-the-app) 14 | - [About the app](#about-the-app) 15 | 16 | 17 | 18 | ## Topics covered 19 | 20 | **Server** 21 | 1. Schema Definition Language 22 | 2. GraphQL types 23 | 3. Resolvers 24 | 4. Enums 25 | 5. Scalars 26 | 6. DataLoader 27 | 7. Mutations 28 | 29 | **Apollo Client** 30 | 1. Basic queries 31 | 2. Fragments 32 | 3. Pagination 33 | 4. Auth 34 | 5. Mutations 35 | 36 | There is way more that we _could_ cover, time permitting, but this is broadly what we'll be focussing on. Depending on the flow of the day, we may have more or less time available and the material will adjust to suit. 37 | 38 | ## Branches & Tags 39 | 40 | You may notice a number of tags and branches peppered throughout the supporting repo. The workshop steps are tagged such that, if needed, we can skip through to completed examples. The required tag will be documented at the beginning of each section. 41 | 42 | ## Code Sandbox 43 | It can be a time consuming challenge to ensure that everyone participating has their machine setup correctly. For this reason we like to use codesandbox to get everyone running nice 'n quickly. 44 | 45 | These are a list of the available branches as codeboxes. When prompted, go ahead and fork them to your own sandbox: 46 | 47 | - Server: Launch Pad https://codesandbox.io/s/github/FormidableLabs/gql-workshop-app/tree/launchpad/server 48 | - Server: Starter https://codesandbox.io/s/github/FormidableLabs/gql-workshop-app/tree/initial-server/server 49 | - Server: Complete https://codesandbox.io/s/github/FormidableLabs/gql-workshop-app/tree/client-starting-point/server 50 | - Client: Start https://codesandbox.io/s/github/FormidableLabs/gql-workshop-app/tree/client-starting-point/client 51 | 52 | --- 53 | 54 | If really must develop locally, the following are the setup steps, proceed at your peril: 55 | 56 | ## System Requirements 57 | 58 | * [git][git] v2.14.1 or greater 59 | * [NodeJS][node] v8.9.4 or greater 60 | * [yarn][yarn] v1.3.0 or greater 61 | * [GraphQL Playground][gqlplayground] _recommended_ 62 | * [Apollo Dev Tools][apollodevtools] for Chrome _recommended_ 63 | 64 | 65 | 66 | All of these must be available in your `PATH`. To verify things are set up 67 | properly, you can run this: 68 | 69 | ``` 70 | git --version 71 | node --version 72 | yarn --version 73 | ``` 74 | 75 | If you have trouble with any of these, learn more about the PATH environment 76 | variable and how to fix it here for [windows](https://www.howtogeek.com/118594/how-to-edit-your-system-path-for-easy-command-line-access/) or 77 | [mac/linux](http://stackoverflow.com/a/24322978/971592). 78 | 79 | 80 | ## Setup 81 | 82 | Once you've verified that your system is setup correctly. Go ahead and clone our workshop project. 83 | 84 | ``` 85 | git clone https://github.com/imranolas/moviedb.git 86 | cd moviedb 87 | ``` 88 | 89 | You should see 2 folders: 90 | 1. `server` 91 | 2. `client` 92 | 93 | Each folder contains a `package.json` and will require a `yarn install` to be run in the package root. 94 | 95 | ## Running the app 96 | 97 | To get the app up and running, run `yarn start` in both roots. 98 | 99 | This will start the GQL server, and the client server in development mode. 100 | 101 | ## About the app 102 | 103 | This app is based on the [The MovieDB API](moviedb). It consists of a GraphQL service that wraps the MovieDB API and serves it to the React client app. This is the completed example but we will be starting from a minimal bootstrapped starting point via git tag. 104 | 105 | [moviedb]: https://www.themoviedb.org/ 106 | [yarn]: https://yarnpkg.com/ 107 | [node]: https://nodejs.org 108 | [git]: https://git-scm.com/ 109 | [gqlplayground]: https://github.com/graphcool/graphql-playground 110 | [apollodevtools]: https://chrome.google.com/webstore/detail/apollo-client-developer-t/jdkknkkbebbapilgoeccciglkfbmbnfm 111 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moviedb-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "^3.4.1" 7 | }, 8 | "dependencies": { 9 | "@urql/exchange-graphcache": "^3.0.1", 10 | "classnames": "^2.2.5", 11 | "graphql": "*", 12 | "graphql-tag": "^2.7.0", 13 | "lodash": "^4.17.5", 14 | "numeral": "^2.0.6", 15 | "prop-types": "^15.6.0", 16 | "react": "^16.6.1", 17 | "react-dom": "^16.6.1", 18 | "react-router-dom": "^5.2.0", 19 | "urql": "^1.9.8" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test --env=jsdom", 25 | "eject": "react-scripts eject" 26 | }, 27 | "browserslist": [ 28 | ">0.2%", 29 | "not dead", 30 | "not ie <= 11", 31 | "not op_mini all" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FormidableLabs/gql-workshop-app/5a3de15284fbac86f52db59f2e16255d72911e4d/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | 16 | 17 | 26 | React App 27 | 28 | 29 | 30 | 33 |
34 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /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 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, BrowserRouter as Router, Route } from 'react-router-dom'; 3 | import { createClient, dedupExchange, fetchExchange, Provider } from 'urql'; 4 | import { cacheExchange } from '@urql/exchange-graphcache'; 5 | 6 | import Login, { AuthProvider } from './components/Login'; 7 | import MovieScreen from './screens/MovieScreen'; 8 | import MoviesScreen from './screens/MoviesScreen'; 9 | 10 | const client = createClient({ 11 | url: 'http://localhost:3001/graphql', 12 | exchanges: [ 13 | dedupExchange, 14 | cacheExchange({ 15 | updates: { 16 | Mutation: { 17 | addToFavorites: (result, args, cache, info) => { 18 | cache.updateQuery( 19 | { 20 | query: ` 21 | query { 22 | favorites { 23 | id 24 | } 25 | } 26 | `, 27 | }, 28 | (data) => { 29 | if (data && data.favorites) { 30 | data.favorites.push(result.addToFavorites); 31 | } 32 | return data; 33 | } 34 | ); 35 | }, 36 | removeFromFavorites: (result, args, cache, info) => { 37 | cache.updateQuery( 38 | { 39 | query: ` 40 | query { 41 | favorites { 42 | id 43 | } 44 | } 45 | `, 46 | }, 47 | (data) => { 48 | if (data && data.favorites) { 49 | data.favorites = data.favorites.filter( 50 | ({ id }) => id !== result.removeFromFavorites.id 51 | ); 52 | } 53 | return data; 54 | } 55 | ); 56 | }, 57 | }, 58 | }, 59 | }), 60 | fetchExchange, 61 | ], 62 | fetchOptions: () => { 63 | return { 64 | headers: { 65 | authorization: localStorage.getItem('token'), 66 | }, 67 | }; 68 | }, 69 | }); 70 | 71 | const App = () => { 72 | return ( 73 | 74 | 75 |
76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 |
85 |
86 | ); 87 | }; 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /client/src/components/Favorite.js: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import classnames from 'classnames'; 3 | import PropTypes from 'prop-types'; 4 | import gql from 'graphql-tag'; 5 | import { useMutation } from 'urql'; 6 | import { AuthContext } from './Login'; 7 | 8 | const AddToFavorites = gql` 9 | mutation($id: ID!) { 10 | addToFavorites(input: { id: $id }) { 11 | id 12 | isFavorite 13 | } 14 | } 15 | `; 16 | const RemoveFromFavorites = gql` 17 | mutation($id: ID!) { 18 | removeFromFavorites(input: { id: $id }) { 19 | id 20 | isFavorite 21 | } 22 | } 23 | `; 24 | 25 | const Favorite = ({ selected, movieId }) => { 26 | const { isLoggedIn } = useContext(AuthContext); 27 | const [, addToFavorites] = useMutation(AddToFavorites); 28 | const [, removeFromFavorites] = useMutation(RemoveFromFavorites); 29 | return ( 30 | { 36 | e.preventDefault(); 37 | if (isLoggedIn) { 38 | selected ? removeFromFavorites({ id: movieId }) : addToFavorites({ id: movieId }); 39 | } 40 | }} 41 | /> 42 | ); 43 | }; 44 | 45 | Favorite.propTypes = { 46 | selected: PropTypes.bool, 47 | movieId: PropTypes.string, 48 | }; 49 | 50 | export default Favorite; 51 | -------------------------------------------------------------------------------- /client/src/components/Login/index.js: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef, useState, createContext, useMemo, useContext } from 'react'; 2 | import gql from 'graphql-tag'; 3 | import './login.css'; 4 | import { useMutation } from 'urql'; 5 | 6 | const LoginMutation = gql` 7 | mutation Login($email: String!) { 8 | login(email: $email) { 9 | token 10 | } 11 | } 12 | `; 13 | 14 | export const AuthContext = createContext(null); 15 | 16 | const Login = () => { 17 | const inputRef = useRef(); 18 | const { isLoggedIn, login, logout, loginError } = useContext(AuthContext); 19 | 20 | const handleLogout = useCallback( 21 | (e) => { 22 | e.preventDefault(); 23 | logout(); 24 | }, 25 | [logout] 26 | ); 27 | 28 | const handleLogin = useCallback( 29 | (e) => { 30 | e.preventDefault(); 31 | console.log(inputRef.current); 32 | login(inputRef.current.value); 33 | }, 34 | [login] 35 | ); 36 | 37 | return ( 38 |
39 | {isLoggedIn ? ( 40 | 43 | ) : ( 44 |
45 | 46 | {loginError} 47 | 48 |
49 | )} 50 |
51 | ); 52 | }; 53 | 54 | export const AuthProvider = ({ children }) => { 55 | const token = useRef(localStorage.getItem('token')); 56 | const [isLoggedIn, setIsLoggedIn] = useState(!!token.current); 57 | const [loginError, setLoginError] = useState(''); 58 | const [, login] = useMutation(LoginMutation); 59 | 60 | const handleLogin = useCallback( 61 | (email) => { 62 | login({ email }).then(({ data, error }) => { 63 | if (error) { 64 | setLoginError(error.graphQLErrors[0].message); 65 | } else { 66 | localStorage.setItem('token', data.login.token); 67 | setIsLoggedIn(true); 68 | setLoginError(null); 69 | } 70 | }); 71 | }, 72 | [login] 73 | ); 74 | 75 | const handleLogout = useCallback((e) => { 76 | localStorage.removeItem('token'); 77 | setIsLoggedIn(false); 78 | }, []); 79 | 80 | const providerValue = useMemo( 81 | () => ({ 82 | isLoggedIn, 83 | loginError, 84 | login: handleLogin, 85 | logout: handleLogout, 86 | }), 87 | [isLoggedIn, loginError, handleLogin, handleLogout] 88 | ); 89 | 90 | return {children}; 91 | }; 92 | 93 | export default Login; 94 | -------------------------------------------------------------------------------- /client/src/components/Login/login.css: -------------------------------------------------------------------------------- 1 | .login { 2 | margin: 0 30px; 3 | position: absolute; 4 | } 5 | 6 | .login input { 7 | font-size: 16px; 8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI'; 9 | padding: 5px; 10 | margin-right: 10px; 11 | } 12 | 13 | .login button { 14 | width: auto; 15 | padding: 5px 10px; 16 | } 17 | -------------------------------------------------------------------------------- /client/src/components/MovieCard/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import gql from 'graphql-tag'; 3 | import { Link } from 'react-router-dom'; 4 | import Favorite from '../Favorite'; 5 | import './movieCard.css'; 6 | 7 | const MovieCard = ({ 8 | addToFavorites, 9 | id, 10 | posterPath, 11 | title, 12 | releaseDate, 13 | overview, 14 | isFavorite, 15 | }) => { 16 | return ( 17 |
18 | 19 | {title} 20 |
21 |

22 | {title} 23 |

24 | {releaseDate} 25 |

{overview}

26 |
27 | 28 |
29 | ); 30 | }; 31 | 32 | MovieCard.fragment = gql` 33 | fragment MovieCard on Movie { 34 | id 35 | title 36 | posterPath(size: MEDIUM) 37 | releaseDate 38 | overview 39 | isFavorite 40 | } 41 | `; 42 | 43 | export default MovieCard; 44 | -------------------------------------------------------------------------------- /client/src/components/MovieCard/movieCard.css: -------------------------------------------------------------------------------- 1 | .movieCard { 2 | color: #333; 3 | background: white; 4 | overflow: hidden; 5 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); 6 | display: flex; 7 | text-align: left; 8 | transition: all 200ms; 9 | border: 1px solid #333; 10 | border-width: 0px 2px 2px 0px; 11 | } 12 | 13 | .movieCard:hover { 14 | box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 15 | } 16 | 17 | .cardImage { 18 | width: 185px; 19 | height: 283px; 20 | display: block; 21 | flex: 0 0 auto; 22 | } 23 | 24 | .cardTitle { 25 | font-family: Lato, sans-serif; 26 | color: #333; 27 | font-size: 17.6px; 28 | font-weight: 600; 29 | } 30 | 31 | .releaseDate { 32 | font-size: 15px; 33 | font-weight: 500; 34 | } 35 | 36 | .cardDetails { 37 | padding: 20px; 38 | } 39 | 40 | .cardOverview { 41 | font-size: 16px; 42 | margin-top: 15px; 43 | display: -webkit-box; 44 | -webkit-line-clamp: 6; 45 | -webkit-box-orient: vertical; 46 | overflow: hidden; 47 | line-height: 1.6; 48 | } 49 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 3 | 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; 4 | font-size: 16px; 5 | line-height: 1.5; 6 | background: #f4f4f4; 7 | } 8 | 9 | html, 10 | body, 11 | #root { 12 | height: 100%; 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | color: #333; 18 | } 19 | 20 | * { 21 | box-sizing: border-box; 22 | } 23 | 24 | h1 { 25 | font-size: 2.5em; 26 | font-weight: 700; 27 | } 28 | 29 | .button { 30 | border-radius: 3px; 31 | background-color: #00fc87; 32 | color: black; 33 | text-transform: uppercase; 34 | padding: 15px 25px; 35 | margin: 20px; 36 | font-weight: 600; 37 | outline: none; 38 | font-size: 16px; 39 | width: 100%; 40 | } 41 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | 5 | import App from './App'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | -------------------------------------------------------------------------------- /client/src/screens/MovieScreen/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import gql from 'graphql-tag'; 3 | import numeral from 'numeral'; 4 | import { useQuery } from 'urql'; 5 | 6 | import './movie.css'; 7 | import Favorite from '../../components/Favorite'; 8 | 9 | const Detail = ({ label, value }) => { 10 | return ( 11 |
12 |
{label}
13 |
{value}
14 |
15 | ); 16 | }; 17 | 18 | const Movie = ({ match }) => { 19 | const [res] = useQuery({ 20 | query: MOVIE_QUERY, 21 | variables: { 22 | movieId: match.params.id, 23 | }, 24 | }); 25 | 26 | if (res.error) { 27 | return null; 28 | } 29 | 30 | if (res.fetching && !res.data) { 31 | return null; 32 | } 33 | 34 | const { 35 | id, 36 | title, 37 | posterPath, 38 | backdropPath, 39 | tagline, 40 | overview, 41 | releaseDate, 42 | runtime, 43 | revenue, 44 | voteAverage, 45 | isFavorite, 46 | } = res.data.movie; 47 | 48 | return ( 49 |
50 |
51 |
52 | {title} 53 |
54 |
55 |

56 | {title} 57 |

58 | {tagline} 59 |

{overview}

60 |
61 | 62 | 63 | 64 | 65 |
66 |
67 |
68 |
69 | ); 70 | }; 71 | 72 | const MOVIE_QUERY = gql` 73 | query Movie($movieId: ID!) { 74 | movie(id: $movieId) { 75 | id 76 | title 77 | backdropPath 78 | posterPath(size: MEDIUM) 79 | tagline 80 | overview 81 | releaseDate 82 | voteAverage 83 | runtime 84 | revenue 85 | isFavorite 86 | } 87 | } 88 | `; 89 | 90 | export default Movie; 91 | -------------------------------------------------------------------------------- /client/src/screens/MovieScreen/movie.css: -------------------------------------------------------------------------------- 1 | .poster { 2 | width: 100%; 3 | display: block; 4 | } 5 | 6 | .posterContainer { 7 | padding: 0; 8 | height: auto; 9 | } 10 | 11 | .backgroundContainer { 12 | background-size: cover; 13 | height: 100%; 14 | justify-content: center; 15 | align-items: center; 16 | display: flex; 17 | } 18 | 19 | .container { 20 | display: flex; 21 | background: rgba(0, 0, 0, 0.85); 22 | padding: 0; 23 | overflow: hidden; 24 | height: fit-content; 25 | } 26 | 27 | .movieInfo { 28 | padding: 25px; 29 | color: #fafafa; 30 | width: 60ch; 31 | } 32 | 33 | .title { 34 | font-size: 2.5em; 35 | text-transform: uppercase; 36 | font-weight: 700; 37 | font-family: Lato, sans-serif; 38 | margin-bottom: 8px; 39 | } 40 | 41 | .tagline { 42 | display: block; 43 | padding-bottom: 0.25em; 44 | color: #00fc87; 45 | font-size: 1.3em; 46 | font-family: Oswald, sans-serif; 47 | } 48 | 49 | .details { 50 | display: flex; 51 | flex-wrap: wrap; 52 | font-family: Oswald, sans-serif; 53 | margin-top: 35px; 54 | } 55 | 56 | .detailValue { 57 | display: block; 58 | color: #00fc87; 59 | font-size: 1.6em; 60 | line-height: 1.1em; 61 | } 62 | 63 | .detailContainer { 64 | flex: 1 1 50%; 65 | margin-bottom: 15px; 66 | } 67 | -------------------------------------------------------------------------------- /client/src/screens/MoviesScreen/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import gql from 'graphql-tag'; 3 | import { Link } from 'react-router-dom'; 4 | import { useQuery } from 'urql'; 5 | import MovieCard from '../../components/MovieCard'; 6 | import './moviesScreen.css'; 7 | 8 | export const FAVORITES_QUERY = gql` 9 | query Favorites { 10 | favorites { 11 | id 12 | } 13 | } 14 | `; 15 | 16 | const FavCount = () => { 17 | const [res] = useQuery({ query: FAVORITES_QUERY }); 18 | if (!res.data) return null; 19 | return ( 20 |
21 | 22 | 23 | {res.data.favorites.length} 24 |
25 | ); 26 | }; 27 | 28 | const Movies = () => { 29 | const [currentPage, setCurrentPage] = useState(1); 30 | const results = new Array(currentPage) 31 | .fill(null) 32 | .map((_, i) => ); 33 | return ( 34 |
35 |
36 |

Discover

37 | 38 | 39 | 40 |
41 |
{results}
42 | 45 |
46 | ); 47 | }; 48 | 49 | export const MOVIES_QUERY = gql` 50 | query Movies($page: Int) { 51 | movies(page: $page) { 52 | id 53 | ...MovieCard 54 | } 55 | } 56 | ${MovieCard.fragment} 57 | `; 58 | 59 | const MovieResults = ({ page }) => { 60 | const [res] = useQuery({ 61 | query: MOVIES_QUERY, 62 | variables: { page }, 63 | }); 64 | 65 | if (!res.data) return null; 66 | 67 | const movies = res.data && res.data.movies; 68 | return ( 69 | <> 70 | {movies.map((movie) => ( 71 | 72 | ))} 73 | 74 | ); 75 | }; 76 | 77 | export default Movies; 78 | -------------------------------------------------------------------------------- /client/src/screens/MoviesScreen/moviesScreen.css: -------------------------------------------------------------------------------- 1 | .moviesScreen { 2 | max-width: 960px; 3 | margin: 0 auto; 4 | text-align: center; 5 | padding: 40px 0 0; 6 | } 7 | 8 | .moviesResults { 9 | display: grid; 10 | grid-template-columns: 480px 480px; 11 | grid-template-rows: auto; 12 | grid-column-gap: 35px; 13 | grid-row-gap: 35px; 14 | } 15 | 16 | .moviesTitle { 17 | margin: 20px 0; 18 | font-size: 2em; 19 | text-align: left; 20 | } 21 | 22 | .favCount { 23 | position: relative; 24 | display: inline-block; 25 | } 26 | 27 | .favCount .fa-circle { 28 | position: absolute; 29 | top: 0; 30 | right: 0; 31 | font-size: 20px; 32 | color: #2afa8b; 33 | } 34 | 35 | .favCount .favCountValue { 36 | position: absolute; 37 | top: 0; 38 | right: 0; 39 | font-weight: 600; 40 | font-size: 13px; 41 | display: block; 42 | width: 20px; 43 | } 44 | 45 | .header { 46 | display: flex; 47 | justify-content: space-between; 48 | align-items: center; 49 | } -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # The Movie DB API 2 | 3 | We’ll be consuming the following endpoints over the course of the day. It may appear to be a limited set but we can explore most of GraphQL’s feature using just this set. 4 | 5 | The base URL is a fastly cache since we don’t want to hit the theMovieDB rate limits. 6 | 7 | #### API key `fa60ffe249c919e7f6c528a4aba8674a` 8 | 9 | ### `GET /3/discover/movies?page={int}&genre_ids={id}` 10 | *Returns a list of latest/popular movies* 11 | - Genres are returned as ids only. We’ll need to resolve these again to get genre names 12 | 13 | ### `GET /3/movie/:id` 14 | *Returns the movie details* 15 | - Genres are returned in full 16 | 17 | ### `GET 3/genre/movie/list` 18 | *Returns the list of movie genres* 19 | 20 | ### `3/movie/:movieId/credits` 21 | *Returns the list of credits for a movie. We’ll want to use this for displaying cast members.* 22 | 23 | ## Images 24 | 25 | ### `https://image.tmdb.org/t/p/:size/:posterPath` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gql-workshop-app", 3 | "version": "1.0.0", 4 | "author": "Imran Sulemanj ", 5 | "license": "MIT", 6 | "workspaces": [ 7 | "server", 8 | "client" 9 | ], 10 | "private": "true", 11 | "devDependencies": { 12 | "wsrun": "^5.2.1" 13 | }, 14 | "resolutions": { 15 | "graphql": "15.3.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "./src/**/*.js", 4 | "./src/schema/*.*" 5 | ], 6 | "events": { 7 | "restart": "clear" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "moviedb", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "nodemon ./src/main.js" 8 | }, 9 | "dependencies": { 10 | "apollo-server": "^2.16.0", 11 | "apollo-server-express": "^2.16.0", 12 | "axios": "^0.17.1", 13 | "body-parser": "^1.18.2", 14 | "camelcase-keys-recursive": "^0.8.2", 15 | "cors": "^2.8.4", 16 | "dataloader": "^2.0.0", 17 | "express": "^4.16.2", 18 | "express-graphql": "^0.11.0", 19 | "graphql": "*", 20 | "graphql-import": "^1.0.2", 21 | "graphql-iso-date": "^3.4.0", 22 | "graphql-tools": "^6.0.14", 23 | "isemail": "^3.1.1", 24 | "isomorphic-fetch": "^2.2.1", 25 | "lodash": "^4.17.4" 26 | }, 27 | "devDependencies": { 28 | "nodemon": "2.0.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/apiClient.js: -------------------------------------------------------------------------------- 1 | const DataLoader = require('dataloader'); 2 | const axios = require('./axios'); 3 | 4 | /** 5 | * Our caching API client. All requests to moviedb will be deduped 6 | * for the lifetime of this instance. Each request will get its own 7 | * instance. 8 | */ 9 | module.exports = function makeApiClient() { 10 | return new DataLoader( 11 | (queries) => { 12 | return Promise.all( 13 | queries.map(([url, config]) => { 14 | return axios.get(url, config); 15 | }) 16 | ); 17 | }, 18 | { cacheKeyFn: (obj) => JSON.stringify(obj) } 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /server/src/authDirective.js: -------------------------------------------------------------------------------- 1 | const { SchemaDirectiveVisitor } = require('apollo-server'); 2 | const { defaultFieldResolver, GraphQLError } = require('graphql'); 3 | 4 | module.exports = class AuthDirective extends SchemaDirectiveVisitor { 5 | visitFieldDefinition(field) { 6 | // Get the resolver if one has been provided or fallback to the default 7 | // implementation, ie resolve matching property name. 8 | 9 | const { resolve = defaultFieldResolver } = field; 10 | 11 | // Overload the field resolver with a higher order resolver which 12 | // will throw or resolve depending on whether the user is authorized.. 13 | field.resolve = function(...args) { 14 | const [, , ctx] = args; 15 | if (!ctx.isLoggedIn) { 16 | throw new GraphQLError('Unauthorized. This action requires a logged in user'); 17 | } 18 | 19 | return resolve.apply(this, args); 20 | }; 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /server/src/axios.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const camelcaseRecursive = require('camelcase-keys-recursive'); 3 | const omit = require('lodash/omit'); 4 | 5 | /** 6 | * Axios is an XHR client that offers some niceties over fetch. We're using is here to 7 | * added some logging to make it explicit what requests are being made. 8 | */ 9 | const instance = axios.create({ 10 | baseURL: 'https://api.themoviedb.org/3', 11 | params: { 12 | api_key: 'fa60ffe249c919e7f6c528a4aba8674a', 13 | }, 14 | 15 | /** 16 | * The response from the movieDB is snakecased and we'd prefer camelcase as it plays 17 | * better with GraphQL's auto resolution of object properties. 18 | */ 19 | transformResponse: (data) => camelcaseRecursive(JSON.parse(data)), 20 | }); 21 | 22 | /** 23 | * Some explicit and colourful logging for each request. 24 | */ 25 | instance.interceptors.request.use(function (config) { 26 | console.info( 27 | '\x1b[32m', 28 | config.method.toUpperCase(), 29 | config.url, 30 | '\x1b[33m \n', 31 | omit(config.params, ['api_key', 'include_adult', 'include_video']), 32 | '\x1b[0m \n' 33 | ); 34 | return config; 35 | }); 36 | 37 | instance.interceptors.response.use( 38 | function (res) { 39 | return res; 40 | }, 41 | (err) => { 42 | console.error(err.stack); 43 | return Promise.reject(err); 44 | } 45 | ); 46 | 47 | module.exports = instance; 48 | -------------------------------------------------------------------------------- /server/src/favoritesStore.js: -------------------------------------------------------------------------------- 1 | class FavoritesStore extends Set {} 2 | 3 | /** 4 | * We're using a Set as a dumb data store. This will hold our favourites in 5 | * memory but obviously won't persist between server restarts. 6 | */ 7 | module.exports = new FavoritesStore(); 8 | -------------------------------------------------------------------------------- /server/src/main.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const { ApolloServer } = require('apollo-server-express'); 3 | const isemail = require('isemail'); 4 | const makeApiClient = require('./apiClient'); 5 | const { importSchema } = require('graphql-import'); 6 | const AuthDirective = require('./authDirective'); 7 | 8 | const resolvers = require('./types'); 9 | 10 | const PORT = process.env.PORT || 3001; 11 | 12 | const app = express(); 13 | 14 | /** 15 | * Mock Authentication Middleware 16 | */ 17 | app.use((req, _, next) => { 18 | const auth = req.headers.authorization || ''; 19 | const email = Buffer.from(auth, 'base64').toString('utf8'); 20 | 21 | if (isemail.validate(email)) { 22 | req.email = email; 23 | } 24 | 25 | return next(); 26 | }); 27 | 28 | const server = new ApolloServer({ 29 | typeDefs: importSchema('./src/schema/schema.graphql'), 30 | resolvers, 31 | schemaDirectives: { 32 | auth: AuthDirective, 33 | }, 34 | tracing: true, 35 | context: ({ req }) => { 36 | return { 37 | isLoggedIn: !!req.email, 38 | apiClient: makeApiClient(), 39 | favoritesStore: require('./favoritesStore'), 40 | }; 41 | }, 42 | playground: { 43 | endpoint: '/graphql', 44 | }, 45 | }); 46 | 47 | server.applyMiddleware({ app, bodyParserConfig: true }); 48 | 49 | app.listen(PORT, () => { 50 | console.log(`Server running at http://localhost:${PORT}`); 51 | }); 52 | -------------------------------------------------------------------------------- /server/src/schema/cast.graphql: -------------------------------------------------------------------------------- 1 | type Cast { 2 | id: ID! 3 | name: String! 4 | character: String! 5 | profilePath: String 6 | } 7 | -------------------------------------------------------------------------------- /server/src/schema/genre.graphql: -------------------------------------------------------------------------------- 1 | type Genre { 2 | id: ID! 3 | name: String! 4 | } 5 | -------------------------------------------------------------------------------- /server/src/schema/movie.graphql: -------------------------------------------------------------------------------- 1 | # import Cast from "./cast.graphql" 2 | # import Genre from "./genre.graphql" 3 | 4 | enum PosterSize { 5 | THUMB 6 | SMALL 7 | MEDIUM 8 | LARGE 9 | ORIGINAL 10 | } 11 | 12 | type Movie { 13 | id: ID! 14 | title: String! 15 | voteAverage: Float! 16 | posterPath(size: PosterSize): String! 17 | backdropPath: String! 18 | overview: String! 19 | tagline: String 20 | runtime: Int 21 | revenue: Int 22 | releaseDate: Date 23 | genres: [Genre!] 24 | cast: [Cast!] 25 | isFavorite: Boolean! 26 | } 27 | -------------------------------------------------------------------------------- /server/src/schema/mutation.graphql: -------------------------------------------------------------------------------- 1 | type LoginResponse { 2 | token: String! 3 | } 4 | 5 | input FavoriteInput { 6 | id: ID! 7 | } 8 | 9 | type Mutation { 10 | login(email: String!): LoginResponse 11 | addToFavorites(input: FavoriteInput!): Movie @auth 12 | removeFromFavorites(input: FavoriteInput!): Movie @auth 13 | } 14 | -------------------------------------------------------------------------------- /server/src/schema/query.graphql: -------------------------------------------------------------------------------- 1 | # import Movie from "./movie.graphql" 2 | # import Genre from "./genre.graphql" 3 | 4 | scalar Date 5 | 6 | type Query { 7 | movie(id: ID!): Movie 8 | movies(page: Int): [Movie!]! 9 | favorites: [Movie!]! 10 | genre(id: ID!): Genre 11 | } 12 | -------------------------------------------------------------------------------- /server/src/schema/schema.graphql: -------------------------------------------------------------------------------- 1 | # import Query from "./query.graphql" 2 | # import Mutation from "./mutation.graphql" 3 | 4 | directive @auth on FIELD_DEFINITION 5 | -------------------------------------------------------------------------------- /server/src/types/cast.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Cast: { 3 | profilePath: ({ profilePath }) => { 4 | return `https://image.tmdb.org/t/p/w500${profilePath}`; 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /server/src/types/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require('./query'), 3 | ...require('./mutation'), 4 | ...require('./movie'), 5 | ...require('./cast'), 6 | }; 7 | -------------------------------------------------------------------------------- /server/src/types/movie.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | PosterSize: { 3 | THUMB: 'w92', 4 | SMALL: 'w185', 5 | MEDIUM: 'w500', 6 | LARGE: 'w780', 7 | ORIGINAL: 'original', 8 | }, 9 | Movie: { 10 | posterPath: (root, { size = 'w500' }) => { 11 | return `https://image.tmdb.org/t/p/${size}${root.posterPath}`; 12 | }, 13 | backdropPath: (root) => { 14 | return `https://image.tmdb.org/t/p/w1280${root.backdropPath}`; 15 | }, 16 | genres: ({ genreIds, genres }, _, { apiClient }) => { 17 | if (genres) { 18 | return genres; 19 | } 20 | 21 | return apiClient 22 | .load(['genre/movie/list']) 23 | .then((res) => res.data.genres) 24 | .then((genres) => genreIds.map((id) => genres.find((genre) => genre.id === id))); 25 | }, 26 | cast: ({ id: movieId }, args, { apiClient }) => { 27 | return apiClient.load([`movie/${movieId}/credits`]).then((res) => res.data.cast); 28 | }, 29 | isFavorite: ({ id }, _, { favoritesStore }) => { 30 | return favoritesStore.has(String(id)); 31 | }, 32 | }, 33 | }; 34 | -------------------------------------------------------------------------------- /server/src/types/mutation.js: -------------------------------------------------------------------------------- 1 | const isemail = require('isemail'); 2 | const { GraphQLError } = require('graphql'); 3 | 4 | module.exports = { 5 | Mutation: { 6 | login: (_, { email }) => { 7 | if (!isemail.validate(email)) { 8 | throw new GraphQLError('Invalid email address'); 9 | } 10 | return { 11 | token: Buffer.from(email).toString('base64'), 12 | }; 13 | }, 14 | 15 | addToFavorites: (_, { input: { id } }, { apiClient, favoritesStore }) => { 16 | favoritesStore.add(id); 17 | return apiClient.load([`movie/${id}`]).then((res) => res.data); 18 | }, 19 | 20 | removeFromFavorites: (_, { input: { id } }, { apiClient, favoritesStore }) => { 21 | favoritesStore.delete(id); 22 | return apiClient.load([`movie/${id}`]).then((res) => res.data); 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /server/src/types/posterSize.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | PosterSize: { 3 | THUMB: 'w92', 4 | SMALL: 'w185', 5 | MEDIUM: 'w500', 6 | LARGE: 'w780', 7 | ORIGINAL: 'original', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /server/src/types/query.js: -------------------------------------------------------------------------------- 1 | const { GraphQLDate } = require('graphql-iso-date'); 2 | 3 | module.exports = { 4 | Date: GraphQLDate, 5 | 6 | Query: { 7 | genre: (_, { id }, { apiClient }) => { 8 | return apiClient 9 | .load(['genre/movie/list']) 10 | .then((res) => res.data.genres) 11 | .then((genres) => genres.find((genre) => genre.id === parseInt(id))); 12 | }, 13 | movies: (_, { page = 1 }, { apiClient }) => { 14 | return apiClient 15 | .load([ 16 | 'discover/movie', 17 | { 18 | params: { 19 | page, 20 | }, 21 | }, 22 | ]) 23 | .then((res) => res.data.results); 24 | }, 25 | movie: (_, { id }, { apiClient }) => { 26 | return apiClient.load([`movie/${id}`]).then((res) => res.data); 27 | }, 28 | favorites: (_, __, { apiClient, favoritesStore }) => { 29 | return Promise.all( 30 | Array.from(favoritesStore).map((id) => 31 | apiClient.load([`movie/${id}`]).then((res) => res.data) 32 | ) 33 | ); 34 | }, 35 | }, 36 | }; 37 | --------------------------------------------------------------------------------