├── .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 |
31 | You need to enable JavaScript to run this app.
32 |
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 |
41 | Log out
42 |
43 | ) : (
44 |
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 |
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 |
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 |
setCurrentPage((page) => page + 1)}>
43 | Load More
44 |
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 |
--------------------------------------------------------------------------------