Skip to content

Commit

Permalink
feature: add errors plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
hayes committed Jun 28, 2021
1 parent bfe383b commit 88509b4
Show file tree
Hide file tree
Showing 47 changed files with 1,915 additions and 341 deletions.
2 changes: 2 additions & 0 deletions .config/beemo/prettier.ts
Expand Up @@ -2,6 +2,8 @@ import { PrettierConfig } from '@beemo/driver-prettier';

const config: PrettierConfig = {
ignore: ['deno'],
useTabs: false,
tabWidth: 2,
};

export default config;
201 changes: 201 additions & 0 deletions docs/plugins/errors.md
@@ -0,0 +1,201 @@
---
name: Errors
menu: Plugins
---

# Errors Plugin

A plugin for easily including errors in your GraphQL schema.

## Usage

### Install

```bash
yarn add @giraphql/plugin-errors
```

### Example Ussage

```typescript
import ErrorsPlugin from '@giraphql/plugin-errors';
const builder = new SchemaBuilder({
plugins: [ErrorsPlugin],
errorOptions: {
defaultTypes: [],
},
});

builder.objectType(Error, {
name: 'Error',
fields: (t) => ({
message: t.exposeString('message'),
}),
});

builder.queryType({
fields: (t) => ({
hello: t.string({
errors: {
type: [Error],
},
args: {
name: t.arg.string({ required: false }),
},
resolve: (parent, { name }) => {
if (name.slice(0, 1) !== name.slice(0, 1).toUpperCase()) {
throw new Error('name must be capitalized');
}

return `hello, ${name || 'World'}`;
},
}),
}),
});
```

The above example will produce a GraphQL schema that looks like:

```graphql
type Error {
message: String!
}

type Query {
hello(name: String!): QueryHelloResult
}

union QueryExtendedErrorListOrError = Error | QueryHelloSuccess

type QueryHelloSuccess {
data: String!
}
```

This field can be queried using fragments like:

```graphql
query {
hello(name: "World") {
__typename
... on Error {
message
}
... on QueryHelloSuccess {
data
}
}
}
```

This plugin works by wrapping fields that define error options in a union type. This union consists
of an object type for each error type defined for the field, and a Success object type that wraps
the returned data. If the fields resolver throws an instance of one of the defined errors, the
errors plugin will automatically resolve to the corresponding error object type.

### Builder options

- `defaultTypes`: An array of Error classes to include in every field with error handling.

### Options on Fields

- `types`: An array of Error classes to catch and handle as error objects in the schema. Will be
merged with `defaultTypes` from builder.
- `union`: An options object for the union type. Can include any normal union type options, and
`name` option for setting a custom name for the union type.
- `result`: An options object for result object type. Can include any normal object type options,
and `name` option for setting a custom name for the result type.
- `dataField`: An options object for the data field on the result object. This field will be named
`data` by default, but can be written by passsing a custom `name` option.

### Recommended Ussage

1. Set up an Error interface
2. Create a BaseError object type
3. Include the Error interface in any custom Error types you define
4. Include the BaseError type in the `defaultTypes` in the builder config

This pattern will allow you to consistently query your schema using a `... on Error { message }`
fragment since all Error classes extend that interface. If your client want's to query details of
more specialized error types, they can just add a fragment for the errors it cares about. This
pattern should also make it easier to make future changes without unexpected breaking changes for
your clients.

The follow is a small example of this pattern:

```typescript
import ErrorsPlugin from '@giraphql/plugin-errors';
const builder = new SchemaBuilder({
plugins: [ErrorsPlugin],
errorOptions: {
defaultTypes: [Error],
},
});

const ErrorInterface = builder.interfaceRef<Error>('Error').implement({
fields: (t) => ({
message: t.exposeString('message'),
}),
});

builder.objectType(Error, {
name: 'BaseError',
isTypeOf: (obj) => obj instanceof Error,
interfaces: [ErrorInterface],
});

class LengthError extends Error {
minLength: number;

constructor(minLength: number) {
super(`string length should be at least ${minLength}`);

this.minLength = minLength;
this.name = 'LengthError';
}
}

builder.objectType(LengthError, {
name: 'LengthError',
interfaces: [ErrorInterface],
isTypeOf: (obj) => obj instanceof LengthError,
fields: (t) => ({
minLength: t.exposeInt('minLength'),
}),
});

builder.queryType({
fields: (t) => ({
// Simple error handling just using base error class
hello: t.string({
errors: {},
args: {
name: t.arg.string({ required: true }),
},
resolve: (parent, { name }) => {
if (!name.startsWith(name.slice(0, 1).toUpperCase())) {
throw new Error('name must be capitalized');
}

return `hello, ${name || 'World'}`;
},
}),
// Handling custom errors
helloWithMinLength: t.string({
errors: {
types: [LengthError],
},
args: {
name: t.arg.string({ required: true }),
},
resolve: (parent, { name }) => {
if (name.length < 5) {
throw new LengthError(5);
}

return `hello, ${name || 'World'}`;
},
}),
}),
});
```
8 changes: 4 additions & 4 deletions package.json
Expand Up @@ -31,11 +31,11 @@
"devDependencies": {
"@beemo/dev": "^0.1.8",
"@types/jest": "^26.0.22",
"@types/node": "^15.12.0",
"@types/node": "^15.12.5",
"@types/node-fetch": "^2.5.10",
"conventional-changelog-beemo": "^2.1.0",
"eslint-plugin-prettier": "^3.3.1",
"graphql": "^15.5.0",
"graphql": "^15.5.1",
"husky": "^6.0.0",
"lerna": "^4.0.0",
"lint-staged": "^11.0.0",
Expand All @@ -45,7 +45,7 @@
"typescript": "4.2.4"
},
"resolutions": {
"graphql": "15.5.0"
"graphql": "15.5.1"
},
"lint-staged": {
"./src/**/*.{ts,tsx}": [
Expand All @@ -71,6 +71,6 @@
},
"homepage": "https://github.com/hayes/giraphql#readme",
"dependencies": {
"zod": "3.1.0"
"zod": "3.2.0"
}
}
2 changes: 1 addition & 1 deletion packages/converter/package.json
Expand Up @@ -24,7 +24,7 @@
"access": "public"
},
"dependencies": {
"graphql": "^15.5.0",
"graphql": "^15.5.1",
"ts-morph": "^11.0.0",
"yargs": "^17.0.1"
},
Expand Down
8 changes: 4 additions & 4 deletions packages/core/package.json
Expand Up @@ -31,9 +31,9 @@
"graphql": ">=15.1.0"
},
"devDependencies": {
"apollo-server": "^2.25.0",
"graphql": ">=15.1.0",
"graphql-scalars": "^1.9.0",
"graphql-tag": "^2.11.0"
"apollo-server": "^2.25.2",
"graphql": ">=15.5.1",
"graphql-scalars": "^1.10.0",
"graphql-tag": "^2.12.5"
}
}
8 changes: 5 additions & 3 deletions packages/core/src/build-cache.ts
Expand Up @@ -433,12 +433,14 @@ export default class BuildCache<Types extends SchemaTypes> {
return ref.type;
}

const { name } = this.configStore.getTypeConfig(ref);
const typeConfig = this.configStore.getTypeConfig(ref);

const type = this.types.get(name);
const type = this.types.get(typeConfig.name);

if (!type) {
throw new TypeError(`Missing implementation of for type ${name}`);
this.buildTypeFromConfig(typeConfig);

return this.types.get(typeConfig.name)!;
}

return type;
Expand Down
10 changes: 7 additions & 3 deletions packages/core/src/builder.ts
Expand Up @@ -131,9 +131,9 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
throw new Error(`Invalid object name ${name} use .create${name}Type() instead`);
}

const ref: ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>> =
const ref =
param instanceof ObjectRef
? param
? (param as ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>>)
: new ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>>(name);

const config: GiraphQLObjectTypeConfig = {
Expand All @@ -159,7 +159,11 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
}

if (options.fields) {
this.configStore.addFields(ref, () => options.fields!(new ObjectFieldBuilder(name, this)));
this.configStore.addFields(ref, () => {
const t = new ObjectFieldBuilder<Types, ParentShape<Types, Param>>(name, this);

return options.fields!(t);
});
}

return ref;
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Expand Up @@ -2,6 +2,7 @@ export * from './context-cache';
export * from './enums';
export * from './input';
export * from './params';
export * from './sort-classes';

export function assertNever(value: never): never {
throw new TypeError(`Unexpected value: ${value}`);
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/utils/sort-classes.ts
@@ -0,0 +1,25 @@
export function classDepth(obj: {}): number {
const proto = Object.getPrototypeOf(obj) as {} | null;

if (!proto) {
return 0;
}

return 1 + classDepth(proto);
}

export function sortClasses<T extends new (...args: any[]) => unknown>(classes: T[]) {
return [...classes].sort((a, b) => {
const depthA = classDepth(a);
const depthB = classDepth(b);

if (depthA > depthB) {
return -1;
}
if (depthB > depthA) {
return 1;
}

return 0;
});
}
2 changes: 1 addition & 1 deletion packages/deno/package.json
Expand Up @@ -12,6 +12,6 @@
"access": "public"
},
"devDependencies": {
"ts-node": "^2.1.1"
"ts-node": "^10.0.0"
}
}
7 changes: 4 additions & 3 deletions packages/deno/packages/core/build-cache.ts
Expand Up @@ -274,10 +274,11 @@ export default class BuildCache<Types extends SchemaTypes> {
if (ref instanceof BuiltinScalarRef) {
return ref.type;
}
const { name } = this.configStore.getTypeConfig(ref);
const type = this.types.get(name);
const typeConfig = this.configStore.getTypeConfig(ref);
const type = this.types.get(typeConfig.name);
if (!type) {
throw new TypeError(`Missing implementation of for type ${name}`);
this.buildTypeFromConfig(typeConfig);
return this.types.get(typeConfig.name)!;
}
return type;
}
Expand Down
9 changes: 6 additions & 3 deletions packages/deno/packages/core/builder.ts
Expand Up @@ -46,8 +46,8 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
if (name === "Query" || name === "Mutation" || name === "Subscription") {
throw new Error(`Invalid object name ${name} use .create${name}Type() instead`);
}
const ref: ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>> = param instanceof ObjectRef
? param
const ref = param instanceof ObjectRef
? (param as ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>>)
: new ObjectRef<OutputShape<Types, Param>, ParentShape<Types, Param>>(name);
const config: GiraphQLObjectTypeConfig = {
kind: "Object",
Expand All @@ -66,7 +66,10 @@ export default class SchemaBuilder<Types extends SchemaTypes> {
this.configStore.addFields(ref, () => fields(new ObjectFieldBuilder<Types, ParentShape<Types, Param>>(name, this)));
}
if (options.fields) {
this.configStore.addFields(ref, () => options.fields!(new ObjectFieldBuilder(name, this)));
this.configStore.addFields(ref, () => {
const t = new ObjectFieldBuilder<Types, ParentShape<Types, Param>>(name, this);
return options.fields!(t);
});
}
return ref;
}
Expand Down

0 comments on commit 88509b4

Please sign in to comment.