├── .gitignore
├── README.md
├── package.json
├── public
├── favicon.ico
└── vercel.svg
├── src
├── components
│ ├── MakeQuery.js
│ ├── Playground.js
│ ├── Preview.js
│ ├── Result.js
│ ├── Toggles.js
│ ├── previews
│ │ ├── ResolverPreview.js
│ │ ├── SchemaPreview.js
│ │ └── UseQueryPreview.js
│ ├── section.js
│ └── toggles
│ │ ├── ErrorPolicyToggle.js
│ │ ├── NullabilityToggle.js
│ │ └── ResolverReturnToggle.js
└── pages
│ ├── _document.js
│ ├── api
│ └── graphql.js
│ └── index.js
└── yarn.lock
/.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 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 |
21 | # debug
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | # local env files
27 | .env.local
28 | .env.development.local
29 | .env.test.local
30 | .env.production.local
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 🚨Apollo Error Handling Visualizer
2 |
3 |
4 |
5 | > This tool shows the relationship between [nullability](http://spec.graphql.org/draft/#sec-Errors-and-Non-Nullability) and error handling in [GraphQL](https://graphql.org/), with specific respect to [Apollo Client](https://www.apollographql.com/docs/react/).
6 | >
7 | > Set the toggles to see how the request / response will change. Scroll to the bottom to see the result!
8 | >
9 | > Questions / Suggestions? Reach out to me at [@mark_larah](https://twitter.com/mark_larah)!
10 |
11 | 
12 |
13 | ## Contributing
14 |
15 | - [Clone a fork of the repo](https://guides.github.com/activities/forking/) and install the project dependencies by running `yarn`
16 | - Make your changes, and start the project by running `yarn dev`
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "apollo-error-handling",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start"
9 | },
10 | "dependencies": {
11 | "@apollo/client": "^3.1.5",
12 | "@graphql-tools/schema": "^6.2.1",
13 | "bumbag": "^1.3.1",
14 | "bumbag-addon-highlighted-code": "^1.3.1",
15 | "bumbag-server": "^1.1.16",
16 | "dedent": "^0.7.0",
17 | "execa": "^4.0.3",
18 | "graphql": "^15.3.0",
19 | "next": "9.5.3",
20 | "react": "16.13.1",
21 | "react-dom": "16.13.1",
22 | "recoil": "^0.0.10"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/magicmark/apollo-error-handling-visualizer/625947018027d60effb238fd4a992cb3352f6d96/public/favicon.ico
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/MakeQuery.js:
--------------------------------------------------------------------------------
1 | import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
2 | import { gql, useQuery } from "@apollo/client";
3 | import { Box, Card, Code, Heading } from "bumbag";
4 |
5 | import HighlightedCode from "bumbag-addon-highlighted-code";
6 | import Section from "./section";
7 |
8 | const GET_BEST_SOCCER_PLAYER = gql`
9 | query {
10 | soccerTeam(name: "Manchester United") {
11 | player(shirtNumber: 9) {
12 | name
13 | }
14 | }
15 | }
16 | `;
17 |
18 | function QueryMaker({ useQueryOptions }) {
19 | const { loading, data, error } = useQuery(
20 | GET_BEST_SOCCER_PLAYER,
21 | useQueryOptions
22 | );
23 |
24 | return (
25 |
26 |
33 | Result
34 |
35 |
36 |
40 | Shows the results for data
and error
{" "}
41 | (from MyComponent
- see above). Click the network tab
42 | in developer tools to poke about some more.
43 | >
44 | }
45 | >
46 |
47 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | export default function MakeQuery(props) {
61 | /**
62 | * recreate apollo client on each render to avoid bad cache/sync issues
63 | * @see https://github.com/apollographql/apollo-client/issues/7045
64 | */
65 | const apolloClient = new ApolloClient({
66 | cache: new InMemoryCache({
67 | // hide __typename from the response
68 | addTypename: false,
69 | }),
70 | uri: "/api/graphql",
71 | });
72 |
73 | return (
74 |
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/src/components/Playground.js:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Card,
4 | Container,
5 | Heading,
6 | Link,
7 | PageContent,
8 | PageWithHeader,
9 | Paragraph,
10 | Stack,
11 | Code,
12 | Text,
13 | TopNav,
14 | applyTheme,
15 | css,
16 | palette,
17 | space,
18 | } from "bumbag";
19 |
20 | import Preview from "./Preview";
21 | import Result from "./Result";
22 | import Toggles from "./Toggles";
23 |
24 | const BorderBox = applyTheme(Box, {
25 | styles: {
26 | base: (styleProps) => css`
27 | padding: ${space(4, "major")(styleProps)}rem 0;
28 | border-bottom: 1px solid
29 | ${palette("white800", { dark: "gray700" })(styleProps)};
30 | `,
31 | },
32 | });
33 |
34 | export default function Playground({ apolloVersion }) {
35 | return (
36 |
39 |
40 |
41 |
42 |
43 |
48 | 🚨
49 |
50 | Apollo Error Handling Visualizer
51 |
52 |
53 |
54 |
55 |
56 |
57 |
61 |
62 |
63 |
64 |
65 |
66 | }
67 | >
68 |
69 |
70 |
71 |
72 |
73 | About
74 |
75 |
76 |
77 | This tool shows the relationship between{" "}
78 |
79 | nullability
80 | {" "}
81 | and error handling in{" "}
82 | GraphQL, with specific
83 | respect to{" "}
84 |
85 | Apollo Client
86 |
87 | .
88 |
89 |
90 | Set the toggles to see how the request / response will change.
91 | Scroll to the bottom to see the result!
92 |
93 |
94 | Questions / Suggestions? Reach out to me at{" "}
95 | @mark_larah!
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 | Made by{" "}
111 |
112 | Mark Larah (@mark_larah)
113 | {" "}
114 | • Built with @apollo/client@{apolloVersion}
115 |
116 |
117 |
118 |
119 | );
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/Preview.js:
--------------------------------------------------------------------------------
1 | import { Box, Button, Columns, Heading } from "bumbag";
2 |
3 | import ResolverPreview from "./previews/ResolverPreview";
4 | import SchemaPreview from "./previews/SchemaPreview";
5 | import UseQueryPreview from "./previews/UseQueryPreview";
6 | import { useState } from "react";
7 |
8 | export default function Preview() {
9 | const [showPreviews, setShowPreviews] = useState(true);
10 | const togglePreviews = () => setShowPreviews(!showPreviews);
11 |
12 | return (
13 |
14 |
22 |
29 | Runtime Code
30 |
31 |
39 |
40 |
41 | {showPreviews && (
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | )}
54 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/components/Result.js:
--------------------------------------------------------------------------------
1 | import MakeQuery from "./MakeQuery";
2 |
3 | import { errorPolicyState } from "./toggles/ErrorPolicyToggle";
4 | import { nullabilityState } from "./toggles/NullabilityToggle";
5 | import { resolverBehaviorState } from "./toggles/ResolverReturnToggle";
6 | import { useRecoilValue } from "recoil";
7 |
8 | export default function Result() {
9 | const { value: errorPolicy } = useRecoilValue(errorPolicyState);
10 | const { value: nullable } = useRecoilValue(nullabilityState);
11 | const { value: resolverBehaviour } = useRecoilValue(resolverBehaviorState);
12 |
13 | const useQueryOptions = {
14 | errorPolicy,
15 | fetchPolicy: "no-cache",
16 | context: {
17 | headers: {
18 | "X-Demo-Nullable": nullable,
19 | "X-Demo-Resolver-Behaviour": resolverBehaviour,
20 | },
21 | },
22 | };
23 |
24 | return ;
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/Toggles.js:
--------------------------------------------------------------------------------
1 | import { Box, Columns, Heading, Text } from "bumbag";
2 |
3 | import ErrorPolicyToggle from "./toggles/ErrorPolicyToggle";
4 | import NullabilityToggle from "./toggles/NullabilityToggle";
5 | import ResolverReturnToggle from "./toggles/ResolverReturnToggle";
6 |
7 | export default function Toggles() {
8 | return (
9 |
10 |
11 | Toggles
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/previews/ResolverPreview.js:
--------------------------------------------------------------------------------
1 | import { Box, Code, Columns, Heading, Link, Text } from "bumbag";
2 |
3 | import HighlightedCode from "bumbag-addon-highlighted-code";
4 | import Section from "../section";
5 | import dedent from "dedent";
6 | import { resolverBehaviorState } from "../toggles/ResolverReturnToggle";
7 | import { useRecoilValue } from "recoil";
8 |
9 | function getResolver(resolverBehaviour) {
10 | if (!resolverBehaviour) return "Select resolver behaviour!";
11 |
12 | if (resolverBehaviour === "throwError") {
13 | return dedent`
14 | Player: {
15 | name(obj, args, context, info) {
16 | // Throw a resolver error (that will be caught by graphql-js)
17 | throw new Error('yikes!');
18 | }
19 | }
20 | `;
21 | }
22 |
23 | if (resolverBehaviour === "networkError") {
24 | return dedent`
25 | Player: {
26 | name(obj, args, context, info) {
27 | // Return HTTP 500 from the server to represent a network level error
28 | throw new InternalServerError();
29 | }
30 | }
31 | `;
32 | }
33 |
34 | if (resolverBehaviour === "returnValue") {
35 | return dedent`
36 | Player: {
37 | name(obj, args, context, info) {
38 | return 'Anthony Martial';
39 | }
40 | }
41 | `;
42 | }
43 |
44 | if (resolverBehaviour === "returnNull") {
45 | return dedent`
46 | Player: {
47 | name(obj, args, context, info) {
48 | return null;
49 | }
50 | }
51 | `;
52 | }
53 | }
54 |
55 | export default function SchemaPreview() {
56 | const { value: resolverBehaviour } = useRecoilValue(resolverBehaviorState);
57 |
58 | return (
59 |
63 | This is the{" "}
64 |
65 | resolver method
66 | {" "}
67 | that will be executed for Player.name
.
68 | >
69 | }
70 | >
71 |
77 |
78 | );
79 | }
80 |
--------------------------------------------------------------------------------
/src/components/previews/SchemaPreview.js:
--------------------------------------------------------------------------------
1 | import { Box, Code, Columns, Heading, Link, Text } from "bumbag";
2 |
3 | import HighlightedCode from "bumbag-addon-highlighted-code";
4 | import Section from "../section";
5 | import dedent from "dedent";
6 | import { nullabilityState } from "../toggles/NullabilityToggle";
7 | import { useRecoilValue } from "recoil";
8 |
9 | function getSchema(nullability) {
10 | return /* GraphQL*/ dedent`
11 | type Player {
12 | name: ${nullability === "nullable" ? "String" : "String!"}
13 | shirtNumber: Int
14 | position: String
15 | }
16 |
17 | type SoccerTeam {
18 | name: String
19 | player(shirtNumber: Int!): Player
20 | }
21 |
22 | type Query {
23 | soccerTeam(name: String!): SoccerTeam
24 | }
25 | `;
26 | }
27 |
28 | export default function SchemaPreview() {
29 | const { value: nullable } = useRecoilValue(nullabilityState);
30 |
31 | return (
32 |
36 | This is the{" "}
37 |
38 | Schema Definiton Language (SDL)
39 | {" "}
40 | that the server will use when executing the request. Note the
41 | difference in nullability for Player.name
.
42 | >
43 | }
44 | >
45 |
51 |
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/src/components/previews/UseQueryPreview.js:
--------------------------------------------------------------------------------
1 | import { Code, Link, Text } from "bumbag";
2 |
3 | import HighlightedCode from "bumbag-addon-highlighted-code";
4 | import Section from "../section";
5 | import dedent from "dedent";
6 | import { errorPolicyState } from "../toggles/ErrorPolicyToggle";
7 | import { useRecoilValue } from "recoil";
8 |
9 | function getUseQueryCode(errorPolicy) {
10 | if (!errorPolicy) return "Select an error policy!";
11 |
12 | const getHook = (padding) => {
13 | const padLines = (lines) =>
14 | lines.map((l) => `${Array(padding).fill(" ").join("")}${l}`).join("\n");
15 |
16 | if (errorPolicy === "none") {
17 | return padLines(["const result = useQuery(GET_BEST_PLAYER);"]);
18 | }
19 |
20 | if (errorPolicy === "all") {
21 | return padLines([
22 | "const result = useQuery(GET_BEST_PLAYER, {",
23 | " errorPolicy: 'all'",
24 | "});",
25 | ]);
26 | }
27 |
28 | if (errorPolicy === "ignore") {
29 | return padLines([
30 | "const result = useQuery(GET_BEST_PLAYER, {",
31 | " errorPolicy: 'ignore'",
32 | "});",
33 | ]);
34 | }
35 | };
36 |
37 | return dedent`
38 | const GET_BEST_PLAYER = gql\`
39 | query {
40 | soccerTeam(name: "Manchester United") {
41 | player(shirtNumber: 9) {
42 | name
43 | }
44 | }
45 | }
46 | \`;
47 |
48 | function MyComponent() {
49 | ${getHook(6)}
50 |
51 | const { data, error, loading } = result;
52 | return loading ? null: JSON.stringify({ data, error });
53 | }
54 | `;
55 | }
56 |
57 | export default function UseQueryPreview() {
58 | const { value: errorPolicy } = useRecoilValue(errorPolicyState);
59 |
60 | return (
61 |
64 | useQuery React Hook (Client)
65 | >
66 | }
67 | description={
68 | <>
69 | The runtime code the React component will use to make the query. Note
70 | the difference in the errorPolicy
attribute.
71 | >
72 | }
73 | >
74 |
80 |
81 | );
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/section.js:
--------------------------------------------------------------------------------
1 | import { Box, Heading, Set, Text } from "bumbag";
2 |
3 | export default function Section({
4 | heading,
5 | description,
6 | children,
7 | alignBottom,
8 | }) {
9 | return (
10 |
11 |
12 |
13 | {heading}
14 |
15 |
16 | {description}
17 |
18 |
19 |
20 |
21 | {children}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/toggles/ErrorPolicyToggle.js:
--------------------------------------------------------------------------------
1 | import { Code, Link, SelectMenu, Text } from "bumbag";
2 | import { atom, useRecoilState } from "recoil";
3 |
4 | import Section from "../section";
5 |
6 | const ERROR_POLICY_OPTIONS = [
7 | {
8 | key: 1,
9 | label: "none",
10 | value: "none",
11 | isDefault: true,
12 | },
13 | { key: 2, label: "ignore", value: "ignore" },
14 | { key: 3, label: "all", value: "all" },
15 | ];
16 |
17 | export const errorPolicyState = atom({
18 | key: "errorPolicy",
19 | default: ERROR_POLICY_OPTIONS[0],
20 | });
21 |
22 | export default function ErrorPolicyToggle() {
23 | const [errorPolicy, setErrorPolicy] = useRecoilState(errorPolicyState);
24 |
25 | return (
26 |
31 | Toggle errorPolicy
to see how the response
32 | objects change.{" "}
33 |
36 | See docs.
37 |
38 | >
39 | }
40 | >
41 | (
44 |
45 |
46 |
47 |
48 |
49 | {isDefault && "(default)"}
50 |
51 | )}
52 | options={ERROR_POLICY_OPTIONS}
53 | placeholder="Select an error policy..."
54 | value={errorPolicy}
55 | />
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/components/toggles/NullabilityToggle.js:
--------------------------------------------------------------------------------
1 | import {Code, Link, SelectMenu, Text} from "bumbag";
2 | import { atom, useRecoilState } from "recoil";
3 |
4 | import Section from "../section";
5 |
6 | const OPTIONS = [
7 | {
8 | key: 1,
9 | label: "Nullable (String)",
10 | value: "nullable",
11 | },
12 | { key: 2, label: "Non-Nullable (String!)", value: "notNullable" },
13 | ];
14 |
15 | export const nullabilityState = atom({
16 | key: "nullability",
17 | default: OPTIONS[0],
18 | });
19 |
20 | export default function NullabilityToggle() {
21 | const [state, setState] = useRecoilState(nullabilityState);
22 |
23 | return (
24 |
29 | Toggle if the Player.name
field is nullable or not to see
30 | how the response objects change.{" "}
31 |
32 | See docs for errors and nullability.
33 |
34 | >
35 | }
36 | >
37 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/src/components/toggles/ResolverReturnToggle.js:
--------------------------------------------------------------------------------
1 | import { SelectMenu, Text } from "bumbag";
2 | import { atom, useRecoilState } from "recoil";
3 |
4 | import Section from "../section";
5 |
6 | const OPTIONS = [
7 |
8 | { key: 1, label: "return 'Anthony Martial'", value: "returnValue" },
9 | { key: 2, label: "return null", value: "returnNull" },
10 | {
11 | key: 3,
12 | label: "throw new Error('yikes')",
13 | value: "throwError",
14 | },
15 | {
16 | key: 4,
17 | label: "throw new InternalServerError()",
18 | value: "networkError",
19 | },
20 | ];
21 |
22 | export const resolverBehaviorState = atom({
23 | key: "resolverBehavior",
24 | default: OPTIONS[0],
25 | });
26 |
27 | export default function ResolverError() {
28 | const [state, setState] = useRecoilState(resolverBehaviorState);
29 |
30 | return (
31 |
36 | (
40 |
41 |
42 |
43 |
44 |
45 | )}
46 | options={OPTIONS}
47 | placeholder="Select a resolver error option..."
48 | value={state}
49 | />
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/src/pages/_document.js:
--------------------------------------------------------------------------------
1 | import Document, { Head, Html, Main, NextScript } from "next/document";
2 |
3 | import { InitializeColorMode } from "bumbag";
4 | import { extractCritical } from "bumbag-server";
5 |
6 | export default class MyDocument extends Document {
7 | static async getInitialProps(ctx) {
8 | const initialProps = await Document.getInitialProps(ctx);
9 | const styles = extractCritical(initialProps.html);
10 | return {
11 | ...initialProps,
12 | styles: (
13 | <>
14 | {initialProps.styles}
15 |
19 | >
20 | ),
21 | };
22 | }
23 | render() {
24 | return (
25 |
26 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/api/graphql.js:
--------------------------------------------------------------------------------
1 | import assert from "assert";
2 | import { graphql } from "graphql";
3 | import { makeExecutableSchema } from "@graphql-tools/schema";
4 |
5 | function getTypeDefs(nullable) {
6 | return /* GraphQL */ `
7 | type Player {
8 | name: ${nullable === "nullable" ? "String" : "String!"}
9 | shirtNumber: Int
10 | position: String
11 | }
12 |
13 | type SoccerTeam {
14 | name: String
15 | player(shirtNumber: Int!): Player
16 | }
17 |
18 | type Query {
19 | soccerTeam(name: String!): SoccerTeam
20 | }
21 | `;
22 | }
23 |
24 | function getResolvers(resolverBehaviour) {
25 | return {
26 | Query: {
27 | soccerTeam: (_, { name }) => {
28 | if (name !== "Manchester United") {
29 | throw new Error("Sorry, we only support Manchester United");
30 | }
31 |
32 | return { name };
33 | },
34 | },
35 | SoccerTeam: {
36 | player: (_, { shirtNumber }) => {
37 | if (shirtNumber !== 9) {
38 | throw new Error("Sorry, we only have data on player 9");
39 | }
40 |
41 | return { shirtNumber: 9 };
42 | },
43 | },
44 | Player: {
45 | name: () => {
46 | if (resolverBehaviour === "throwError") {
47 | throw new Error("yikes!");
48 | } else if (resolverBehaviour === "returnValue") {
49 | return "Anthony Martial";
50 | } else if (resolverBehaviour === "returnNull") {
51 | return null;
52 | }
53 |
54 | throw new Error(
55 | `bad value for resolverBehaviour - ${resolverBehaviour}`
56 | );
57 | },
58 | position: () => "Forward",
59 | },
60 | };
61 | }
62 |
63 | function executeQuery(query, variables, resolverBehaviour, nullable) {
64 | const schema = makeExecutableSchema({
65 | typeDefs: getTypeDefs(nullable),
66 | resolvers: getResolvers(resolverBehaviour),
67 | });
68 |
69 | return graphql(schema, query, null, null, variables);
70 | }
71 |
72 | export default async (req, res) => {
73 | assert(
74 | typeof req.headers["x-demo-nullable"] === "string",
75 | "Expected x-demo-nullable header to be set"
76 | );
77 | assert(
78 | typeof req.headers["x-demo-resolver-behaviour"] === "string",
79 | "Expected x-demo-resolver-behaviour header to be set"
80 | );
81 | assert(req.method === "POST", "I only support POST requests");
82 |
83 | const { query, variables } = req.body;
84 | assert(typeof query === "string", "expected to recieve a query string");
85 |
86 | if (req.headers["x-demo-resolver-behaviour"] === "networkError") {
87 | res.statusCode = 500;
88 | res.end('Internal Server Error');
89 | return;
90 | }
91 |
92 | const response = await executeQuery(
93 | query,
94 | variables,
95 | req.headers["x-demo-resolver-behaviour"],
96 | req.headers["x-demo-nullable"]
97 | );
98 |
99 | res.statusCode = 200;
100 | res.json(response);
101 | };
102 |
--------------------------------------------------------------------------------
/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import { Provider as BumbagProvider } from "bumbag";
2 | import Head from "next/head";
3 | import Playground from "../components/Playground";
4 | import { RecoilRoot } from "recoil";
5 | import execa from "execa";
6 |
7 | export default function Index({ apolloVersion }) {
8 | return (
9 | <>
10 |
11 | Apollo Error Handling Visualizer
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
27 | >
28 | );
29 | }
30 |
31 | export async function getStaticProps() {
32 | const { stdout } = await execa.command(
33 | "yarn list --pattern @apollo/client --depth=0 --json"
34 | );
35 |
36 | const apolloVersion = JSON.parse(stdout).data.trees[0].name.split("@")[2];
37 |
38 | return {
39 | props: { apolloVersion },
40 | };
41 | }
42 |
--------------------------------------------------------------------------------