Skip to content

Commit 8fed4f3

Browse files
committed
[errors] add onResolvedError callback
1 parent 23c9161 commit 8fed4f3

File tree

7 files changed

+388
-12
lines changed

7 files changed

+388
-12
lines changed

.changeset/cool-owls-sink.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pothos/plugin-errors": minor
3+
---
4+
5+
Add an onResolvedError callback to builder options for logging errors handled by the plugin

packages/plugin-errors/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const builder = new SchemaBuilder({
2323
plugins: [ErrorsPlugin],
2424
errors: {
2525
defaultTypes: [],
26+
// onResolvedError: (error) => console.error('Handled error:', error),
2627
},
2728
});
2829

@@ -98,6 +99,7 @@ errors plugin will automatically resolve to the corresponding error object type.
9899
- `defaultTypes`: An array of Error classes to include in every field with error handling.
99100
- `directResult`: Sets the default for `directResult` option on fields (only affects non-list
100101
fields)
102+
- `onResolvedError`: A callback function that is called when an error is handled by the plugin
101103
- `defaultResultOptions`: Sets the defaults for `result` option on fields.
102104
- `name`: Function to generate a custom name on the generated result types.
103105
```ts

packages/plugin-errors/src/index.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export class PothosErrorsPlugin<Types extends SchemaTypes> extends BasePlugin<Ty
191191
const pothosItemErrors = fieldConfig.extensions?.pothosItemErrors as
192192
| (typeof Error)[]
193193
| undefined;
194+
const onResolvedError = this.builder.options.errors?.onResolvedError;
194195

195196
if (!pothosErrors && !pothosItemErrors) {
196197
return resolver;
@@ -205,7 +206,7 @@ export class PothosErrorsPlugin<Types extends SchemaTypes> extends BasePlugin<Ty
205206
const result = (await resolver(source, args, context, info)) as never;
206207

207208
if (pothosItemErrors && result && typeof result === 'object' && Symbol.iterator in result) {
208-
return yieldErrors(result, pothosItemErrors);
209+
return yieldErrors(result, pothosItemErrors, onResolvedError);
209210
}
210211

211212
if (
@@ -214,13 +215,12 @@ export class PothosErrorsPlugin<Types extends SchemaTypes> extends BasePlugin<Ty
214215
typeof result === 'object' &&
215216
Symbol.asyncIterator in result
216217
) {
217-
console.log(result, yieldAsyncErrors);
218-
return yieldAsyncErrors(result, pothosItemErrors);
218+
return yieldAsyncErrors(result, pothosItemErrors, onResolvedError);
219219
}
220220

221221
return result;
222222
} catch (error: unknown) {
223-
return wrapOrThrow(error, pothosErrors ?? []);
223+
return wrapOrThrow(error, pothosErrors ?? [], onResolvedError);
224224
}
225225
};
226226
}
@@ -230,6 +230,7 @@ export class PothosErrorsPlugin<Types extends SchemaTypes> extends BasePlugin<Ty
230230
fieldConfig: PothosOutputFieldConfig<Types>,
231231
): GraphQLFieldResolver<unknown, Types['Context'], object> | undefined {
232232
const pothosErrors = fieldConfig.extensions?.pothosErrors as (typeof Error)[] | undefined;
233+
const onResolvedError = this.builder.options.errors?.onResolvedError;
233234

234235
if (!pothosErrors) {
235236
return subscribe;
@@ -248,7 +249,7 @@ export class PothosErrorsPlugin<Types extends SchemaTypes> extends BasePlugin<Ty
248249
yield value;
249250
}
250251
} catch (error: unknown) {
251-
yield wrapOrThrow(error, pothosErrors ?? []);
252+
yield wrapOrThrow(error, pothosErrors ?? [], onResolvedError);
252253
}
253254
}
254255

@@ -371,9 +372,14 @@ SchemaBuilder.registerPlugin(pluginName, PothosErrorsPlugin, {
371372
}),
372373
});
373374

374-
function wrapOrThrow(error: unknown, pothosErrors: ErrorConstructor[]) {
375+
function wrapOrThrow(
376+
error: unknown,
377+
pothosErrors: ErrorConstructor[],
378+
onResolvedError?: (error: Error) => void,
379+
) {
375380
for (const errorType of pothosErrors) {
376381
if (error instanceof errorType) {
382+
onResolvedError?.(error);
377383
const result = createErrorProxy(error, errorType, { wrapped: true });
378384

379385
errorTypeMap.set(result, errorType);
@@ -385,30 +391,38 @@ function wrapOrThrow(error: unknown, pothosErrors: ErrorConstructor[]) {
385391
throw error;
386392
}
387393

388-
function* yieldErrors(result: Iterable<unknown>, pothosErrors: ErrorConstructor[]) {
394+
function* yieldErrors(
395+
result: Iterable<unknown>,
396+
pothosErrors: ErrorConstructor[],
397+
onResolvedError?: (error: Error) => void,
398+
) {
389399
try {
390400
for (const item of result) {
391401
if (item instanceof Error) {
392-
yield wrapOrThrow(item, pothosErrors);
402+
yield wrapOrThrow(item, pothosErrors, onResolvedError);
393403
} else {
394404
yield item;
395405
}
396406
}
397407
} catch (error: unknown) {
398-
yield wrapOrThrow(error, pothosErrors);
408+
yield wrapOrThrow(error, pothosErrors, onResolvedError);
399409
}
400410
}
401411

402-
async function* yieldAsyncErrors(result: AsyncIterable<unknown>, pothosErrors: ErrorConstructor[]) {
412+
async function* yieldAsyncErrors(
413+
result: AsyncIterable<unknown>,
414+
pothosErrors: ErrorConstructor[],
415+
onResolvedError?: (error: Error) => void,
416+
) {
403417
try {
404418
for await (const item of result) {
405419
if (item instanceof Error) {
406-
yield wrapOrThrow(item, pothosErrors);
420+
yield wrapOrThrow(item, pothosErrors, onResolvedError);
407421
} else {
408422
yield item;
409423
}
410424
}
411425
} catch (error: unknown) {
412-
yield wrapOrThrow(error, pothosErrors);
426+
yield wrapOrThrow(error, pothosErrors, onResolvedError);
413427
}
414428
}

packages/plugin-errors/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export interface ErrorsPluginOptions<Types extends SchemaTypes> {
3535
name?: GetTypeName;
3636
}
3737
>;
38+
/**
39+
* Callback for logging any errors which are handled by the errors plugin, this
40+
* function will not be called for errors that are not handled by the errors plugin
41+
**/
42+
onResolvedError?: (error: Error) => void;
3843
}
3944

4045
export type ErrorFieldOptions<

packages/plugin-errors/tests/index.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import SchemaBuilder from '@pothos/core';
12
import { execute, printSchema, subscribe } from 'graphql';
23
import { gql } from 'graphql-tag';
4+
import ErrorPlugin from '../src';
35
import { builder, builderWithCustomErrorTypeNames } from './example/builder';
46
import { createSchema } from './example/schema';
57

@@ -438,3 +440,236 @@ describe('errors plugin', () => {
438440
`);
439441
});
440442
});
443+
444+
describe('onResolvedError callback', () => {
445+
it('calls onResolvedError when errors are handled', async () => {
446+
const resolvedErrors: Error[] = [];
447+
448+
const testBuilder = new SchemaBuilder<{}>({
449+
plugins: [ErrorPlugin],
450+
errors: {
451+
defaultTypes: [],
452+
onResolvedError: (error) => {
453+
resolvedErrors.push(error);
454+
},
455+
},
456+
});
457+
458+
testBuilder.objectType(Error, {
459+
name: 'Error',
460+
fields: (t) => ({
461+
message: t.exposeString('message'),
462+
}),
463+
});
464+
465+
testBuilder.queryType({
466+
fields: (t) => ({
467+
testField: t.field({
468+
type: 'String',
469+
errors: {
470+
types: [Error],
471+
},
472+
resolve: () => {
473+
throw new Error('Test error message');
474+
},
475+
}),
476+
}),
477+
});
478+
479+
const testSchema = testBuilder.toSchema();
480+
481+
const result = await execute({
482+
schema: testSchema,
483+
document: gql`
484+
query {
485+
testField {
486+
__typename
487+
... on Error {
488+
message
489+
}
490+
}
491+
}
492+
`,
493+
contextValue: {},
494+
});
495+
496+
expect(resolvedErrors).toHaveLength(1);
497+
expect(resolvedErrors[0]).toBeInstanceOf(Error);
498+
expect(resolvedErrors[0].message).toBe('Test error message');
499+
500+
expect(result).toMatchInlineSnapshot(`
501+
{
502+
"data": {
503+
"testField": {
504+
"__typename": "Error",
505+
"message": "Test error message",
506+
},
507+
},
508+
}
509+
`);
510+
});
511+
512+
it('does not call onResolvedError for unhandled errors', async () => {
513+
const resolvedErrors: Error[] = [];
514+
515+
class HandledError extends Error {
516+
constructor(message: string) {
517+
super(message);
518+
this.name = 'HandledError';
519+
}
520+
}
521+
522+
class UnhandledError extends Error {
523+
constructor(message: string) {
524+
super(message);
525+
this.name = 'UnhandledError';
526+
}
527+
}
528+
529+
const testBuilder = new SchemaBuilder<{}>({
530+
plugins: [ErrorPlugin],
531+
errors: {
532+
defaultTypes: [],
533+
onResolvedError: (error) => {
534+
resolvedErrors.push(error);
535+
},
536+
},
537+
});
538+
539+
testBuilder.objectType(HandledError, {
540+
name: 'HandledError',
541+
fields: (t) => ({
542+
message: t.exposeString('message'),
543+
}),
544+
});
545+
546+
testBuilder.queryType({
547+
fields: (t) => ({
548+
testField: t.field({
549+
type: 'String',
550+
errors: {
551+
types: [HandledError],
552+
},
553+
resolve: () => {
554+
throw new UnhandledError('This error is not handled by the plugin');
555+
},
556+
}),
557+
}),
558+
});
559+
560+
const testSchema = testBuilder.toSchema();
561+
562+
const result = await execute({
563+
schema: testSchema,
564+
document: gql`
565+
query {
566+
testField {
567+
__typename
568+
}
569+
}
570+
`,
571+
contextValue: {},
572+
});
573+
574+
expect(resolvedErrors).toHaveLength(0);
575+
expect(result.errors).toBeDefined();
576+
expect(result.errors?.[0].message).toContain('This error is not handled by the plugin');
577+
});
578+
579+
it('calls onResolvedError for handled errors but not unhandled ones', async () => {
580+
const resolvedErrors: Error[] = [];
581+
582+
class HandledError extends Error {
583+
constructor(message: string) {
584+
super(message);
585+
this.name = 'HandledError';
586+
}
587+
}
588+
589+
class UnhandledError extends Error {
590+
constructor(message: string) {
591+
super(message);
592+
this.name = 'UnhandledError';
593+
}
594+
}
595+
596+
const testBuilder = new SchemaBuilder<{}>({
597+
plugins: [ErrorPlugin],
598+
errors: {
599+
defaultTypes: [],
600+
onResolvedError: (error) => {
601+
resolvedErrors.push(error);
602+
},
603+
},
604+
});
605+
606+
testBuilder.objectType(HandledError, {
607+
name: 'HandledError',
608+
fields: (t) => ({
609+
message: t.exposeString('message'),
610+
}),
611+
});
612+
613+
testBuilder.queryType({
614+
fields: (t) => ({
615+
handledField: t.field({
616+
type: 'String',
617+
errors: {
618+
types: [HandledError],
619+
},
620+
resolve: () => {
621+
throw new HandledError('This error is handled');
622+
},
623+
}),
624+
unhandledField: t.field({
625+
type: 'String',
626+
errors: {
627+
types: [HandledError], // Only catching HandledError
628+
},
629+
resolve: () => {
630+
throw new UnhandledError('This error is not handled');
631+
},
632+
}),
633+
}),
634+
});
635+
636+
const testSchema = testBuilder.toSchema();
637+
638+
// Test handled error
639+
const handledResult = await execute({
640+
schema: testSchema,
641+
document: gql`
642+
query {
643+
handledField {
644+
__typename
645+
... on HandledError {
646+
message
647+
}
648+
}
649+
}
650+
`,
651+
contextValue: {},
652+
});
653+
654+
expect(resolvedErrors).toHaveLength(1);
655+
expect(resolvedErrors[0]).toBeInstanceOf(HandledError);
656+
expect(resolvedErrors[0].message).toBe('This error is handled');
657+
expect(handledResult.errors).toBeUndefined();
658+
659+
const unhandledResult = await execute({
660+
schema: testSchema,
661+
document: gql`
662+
query {
663+
unhandledField {
664+
__typename
665+
}
666+
}
667+
`,
668+
contextValue: {},
669+
});
670+
671+
expect(resolvedErrors).toHaveLength(1);
672+
expect(unhandledResult.errors).toBeDefined();
673+
expect(unhandledResult.errors?.[0].message).toContain('This error is not handled');
674+
});
675+
});

0 commit comments

Comments
 (0)