From 66cde8ea75b0d147865b1e74fc8eb8056016f2dc Mon Sep 17 00:00:00 2001 From: iamchanii Date: Wed, 5 Jul 2023 17:09:07 +0900 Subject: [PATCH] feat: support errors plugin --- README.md | 10 +-- example/README.md | 13 ---- examples/01-basic/README.md | 4 +- {example => examples/01-basic}/package.json | 2 +- {example => examples/01-basic}/src/Dice.ts | 0 {example => examples/01-basic}/src/Fetch.ts | 0 {example => examples/01-basic}/src/GitHub.ts | 0 {example => examples/01-basic}/src/index.ts | 0 {example => examples/01-basic}/tsconfig.json | 2 +- examples/02-with-error-plugin/README.md | 1 - examples/02-with-errors-plugin/README.md | 3 + examples/02-with-errors-plugin/package.json | 17 +++++ examples/02-with-errors-plugin/src/Fetch.ts | 33 ++++++++++ examples/02-with-errors-plugin/src/GitHub.ts | 66 +++++++++++++++++++ examples/02-with-errors-plugin/src/index.ts | 68 ++++++++++++++++++++ examples/02-with-errors-plugin/tsconfig.json | 8 +++ package.json | 1 + pnpm-lock.yaml | 60 ++++++++++++++++- src/field-builder.ts | 14 +++- src/global-types.ts | 4 +- src/types.ts | 9 ++- 21 files changed, 287 insertions(+), 28 deletions(-) delete mode 100644 example/README.md rename {example => examples/01-basic}/package.json (90%) rename {example => examples/01-basic}/src/Dice.ts (100%) rename {example => examples/01-basic}/src/Fetch.ts (100%) rename {example => examples/01-basic}/src/GitHub.ts (100%) rename {example => examples/01-basic}/src/index.ts (100%) rename {example => examples/01-basic}/tsconfig.json (76%) delete mode 100644 examples/02-with-error-plugin/README.md create mode 100644 examples/02-with-errors-plugin/README.md create mode 100644 examples/02-with-errors-plugin/package.json create mode 100644 examples/02-with-errors-plugin/src/Fetch.ts create mode 100644 examples/02-with-errors-plugin/src/GitHub.ts create mode 100644 examples/02-with-errors-plugin/src/index.ts create mode 100644 examples/02-with-errors-plugin/tsconfig.json diff --git a/README.md b/README.md index f26ec6c..34c3171 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ The Effect integration plugin adds the `t.effect()` field method to implement resolvers using [Effect](https://effect.website/). +## Examples + +- [01-basic](/examples/01-basic/) +- [02-with-error-plugin](/examples/02-with-error-plugin/) + ## Usage ### Install @@ -257,11 +262,6 @@ builder.queryFields(t => ({ })); ``` -## Examples - -- [01-basic](/examples/01-basic/) -- [02-with-error-plugin](/examples/02-with-error-plugin/) - ## Acknowledges - Pothos by [@hayes](https://github.com/hayes) ([GitHub](https://github.com/hayes/pothos)/[Docs](https://pothos-graphql.dev/)) - A nice GraphQL Schema builder. I heavily relied on the README for this project and The documentation of the plugin implementation is excellent. diff --git a/example/README.md b/example/README.md deleted file mode 100644 index 9ba2b5e..0000000 --- a/example/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# Example - -This is a demo showing minimal functionality. - -https://github.com/iamchanii/pothos-plugin-effect/assets/26643843/6f8d3d39-9c0b-440b-96ed-33f8da6d4d46 - -## Setup - -```bash -pnpm install && pnpm dev -``` - -Open http://localhost:4000/graphql diff --git a/examples/01-basic/README.md b/examples/01-basic/README.md index 00d7bdd..d28f1a5 100644 --- a/examples/01-basic/README.md +++ b/examples/01-basic/README.md @@ -1 +1,3 @@ -WIP +# Example: Basic + +This example demonstrates basic usage of this plugin. diff --git a/example/package.json b/examples/01-basic/package.json similarity index 90% rename from example/package.json rename to examples/01-basic/package.json index cc56b6a..da18add 100644 --- a/example/package.json +++ b/examples/01-basic/package.json @@ -1,5 +1,5 @@ { - "name": "example", + "name": "examples-01-basic", "type": "module", "scripts": { "dev": "tsx watch src/index.ts" diff --git a/example/src/Dice.ts b/examples/01-basic/src/Dice.ts similarity index 100% rename from example/src/Dice.ts rename to examples/01-basic/src/Dice.ts diff --git a/example/src/Fetch.ts b/examples/01-basic/src/Fetch.ts similarity index 100% rename from example/src/Fetch.ts rename to examples/01-basic/src/Fetch.ts diff --git a/example/src/GitHub.ts b/examples/01-basic/src/GitHub.ts similarity index 100% rename from example/src/GitHub.ts rename to examples/01-basic/src/GitHub.ts diff --git a/example/src/index.ts b/examples/01-basic/src/index.ts similarity index 100% rename from example/src/index.ts rename to examples/01-basic/src/index.ts diff --git a/example/tsconfig.json b/examples/01-basic/tsconfig.json similarity index 76% rename from example/tsconfig.json rename to examples/01-basic/tsconfig.json index 4f5ec87..287b7c6 100644 --- a/example/tsconfig.json +++ b/examples/01-basic/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "../tsconfig.json", + "extends": "../../tsconfig.json", "compilerOptions": { "allowImportingTsExtensions": false, "outDir": "./dist" diff --git a/examples/02-with-error-plugin/README.md b/examples/02-with-error-plugin/README.md deleted file mode 100644 index 00d7bdd..0000000 --- a/examples/02-with-error-plugin/README.md +++ /dev/null @@ -1 +0,0 @@ -WIP diff --git a/examples/02-with-errors-plugin/README.md b/examples/02-with-errors-plugin/README.md new file mode 100644 index 0000000..277cca3 --- /dev/null +++ b/examples/02-with-errors-plugin/README.md @@ -0,0 +1,3 @@ +# Example: With Errors Plugin + +This example demonstrates how to use [Errors Plugin](https://pothos-graphql.dev/docs/plugins/errors) and get some leverage in catching errors. diff --git a/examples/02-with-errors-plugin/package.json b/examples/02-with-errors-plugin/package.json new file mode 100644 index 0000000..3b58a4f --- /dev/null +++ b/examples/02-with-errors-plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "examples-02-with-errors-plugin", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "@pothos/core": "^3.30.0", + "@pothos/plugin-errors": "^3.11.1", + "graphql": "^16.6.0", + "graphql-yoga": "^4.0.1", + "pothos-plugin-effect": "workspace:^" + }, + "devDependencies": { + "tsx": "^3.12.7" + } +} \ No newline at end of file diff --git a/examples/02-with-errors-plugin/src/Fetch.ts b/examples/02-with-errors-plugin/src/Fetch.ts new file mode 100644 index 0000000..a2d549c --- /dev/null +++ b/examples/02-with-errors-plugin/src/Fetch.ts @@ -0,0 +1,33 @@ +import * as Context from '@effect/data/Context'; +import { pipe } from '@effect/data/Function'; +import * as Effect from '@effect/io/Effect'; +import * as Layer from '@effect/io/layer'; + +export class RequestError { + readonly _tag = 'RequestError'; + constructor(readonly response: Response | null) {} +} + +export interface Fetch { + get(input: RequestInfo | URL, init?: RequestInit | undefined): Effect.Effect; +} + +export const Fetch = Context.Tag(); + +export const FetchLive = Layer.succeed( + Fetch, + Fetch.of({ + get: (input, init) => + pipe( + Effect.tryCatchPromise( + () => fetch(input, init), + () => new RequestError(null), + ), + Effect.flatMap(response => + response.ok + ? Effect.succeed(response) + : Effect.fail(new RequestError(response)) + ), + ), + }), +); diff --git a/examples/02-with-errors-plugin/src/GitHub.ts b/examples/02-with-errors-plugin/src/GitHub.ts new file mode 100644 index 0000000..39a89aa --- /dev/null +++ b/examples/02-with-errors-plugin/src/GitHub.ts @@ -0,0 +1,66 @@ +import * as Context from '@effect/data/Context'; +import * as Effect from '@effect/io/Effect'; +import * as Layer from '@effect/io/layer'; +import { pipe } from 'graphql-yoga'; + +import { Fetch } from './Fetch.js'; + +export class NotFound extends Error { + readonly _tag = 'NotFound'; + + constructor(username: string) { + super(`User ${username} not found`); + + this.name = 'NotFound'; + } +} + +export class ForbiddenUser extends Error { + readonly _tag = 'ForbiddenUser'; + + constructor(username: string) { + super(`User ${username} is forbidden`); + + this.name = 'ForbiddenUser'; + } +} + +export interface GitHubUser { + followers: number; + name: string; +} + +export interface GitHub { + getUser(username: string): Effect.Effect; +} + +export const GitHub = Context.Tag(); + +export const GitHubLive = Layer.effect( + GitHub, + pipe( + Fetch, + Effect.map(fetch => + GitHub.of({ + getUser: username => + pipe( + Effect.if( + username === 'admin', + /* onTrue */ Effect.fail(new ForbiddenUser(username)), + /* onFalse */ Effect.succeed(username), + ), + Effect.flatMap(username => fetch.get(`https://api.github.com/users/${username}`)), + Effect.flatMap(response => Effect.promise(() => response.json() as Promise)), + Effect.catchTag('RequestError', () => Effect.fail(new NotFound(username))), + ), + }) + ), + ), +); + +export const GitHubStub = Layer.succeed( + GitHub, + GitHub.of({ + getUser: username => Effect.succeed({ followers: 10_000_000, name: username }), + }), +); diff --git a/examples/02-with-errors-plugin/src/index.ts b/examples/02-with-errors-plugin/src/index.ts new file mode 100644 index 0000000..a3d70b2 --- /dev/null +++ b/examples/02-with-errors-plugin/src/index.ts @@ -0,0 +1,68 @@ +import { pipe } from '@effect/data/Function'; +import * as Effect from '@effect/io/Effect'; +import SchemaBuilder from '@pothos/core'; +import ErrorsPlugin from '@pothos/plugin-errors'; +import { createYoga } from 'graphql-yoga'; +import { createServer } from 'node:http'; +import EffectPlugin from 'pothos-plugin-effect'; + +import { FetchLive } from './Fetch.js'; +import { ForbiddenUser, GitHub, GitHubLive, NotFound } from './GitHub.js'; + +const builder = new SchemaBuilder({ + plugins: [ErrorsPlugin, EffectPlugin], +}); + +const ErrorInterface = builder.interfaceRef('Error').implement({ + fields: (t) => ({ + message: t.exposeString('message'), + }), +}); + +builder.objectType(Error, { + interfaces: [ErrorInterface], + name: 'BaseError', +}); + +builder.objectType(NotFound, { + interfaces: [ErrorInterface], + name: 'NotFound', +}); + +builder.objectType(ForbiddenUser, { + interfaces: [ErrorInterface], + name: 'ForbiddenUser', +}); + +builder.queryType({ + fields: t => ({ + githubUserFollowers: t.effect({ + args: { + username: t.arg.string({ required: true }), + }, + effect: { + layers: [FetchLive, GitHubLive], + }, + errors: { + types: [NotFound, ForbiddenUser], + }, + resolve: (_parent, args) => + pipe( + GitHub, + Effect.flatMap(github => github.getUser(args.username)), + Effect.map(user => user.followers), + ), + type: 'Int', + }), + }), +}); + +const schema = builder.toSchema({ sortSchema: true }); + +const yoga = createYoga({ schema }); + +const server = createServer(yoga); + +server.listen(4000, () => { + console.info('Server is running on http://localhost:4000/graphql'); +}); diff --git a/examples/02-with-errors-plugin/tsconfig.json b/examples/02-with-errors-plugin/tsconfig.json new file mode 100644 index 0000000..287b7c6 --- /dev/null +++ b/examples/02-with-errors-plugin/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/package.json b/package.json index 12b68d8..dc15031 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@effect/data": "^0.12.9", "@effect/io": "^0.27.1", "@pothos/core": "^3.30.0", + "@pothos/plugin-errors": "^3.11.1", "@swc/core": "^1.3.66", "@swc/jest": "^0.2.26", "@types/jest": "^29.5.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a28e70..c7306b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@pothos/core': specifier: ^3.30.0 version: 3.30.0(graphql@16.6.0) + '@pothos/plugin-errors': + specifier: ^3.11.1 + version: 3.11.1(@pothos/core@3.30.0)(graphql@16.6.0) '@swc/core': specifier: ^1.3.66 version: 1.3.66 @@ -64,7 +67,7 @@ importers: specifier: ^5.1.3 version: 5.1.3 - example: + examples/01-basic: dependencies: '@pothos/core': specifier: ^3.30.0 @@ -77,7 +80,51 @@ importers: version: 4.0.1(graphql@16.6.0) pothos-plugin-effect: specifier: workspace:^ - version: link:.. + version: link:../.. + devDependencies: + tsx: + specifier: ^3.12.7 + version: 3.12.7 + + examples/02-with-error-plugin: + dependencies: + '@pothos/core': + specifier: ^3.30.0 + version: 3.30.0(graphql@16.6.0) + '@pothos/plugin-errors': + specifier: ^3.11.1 + version: 3.11.1(@pothos/core@3.30.0)(graphql@16.6.0) + graphql: + specifier: ^16.6.0 + version: 16.6.0 + graphql-yoga: + specifier: ^4.0.1 + version: 4.0.1(graphql@16.6.0) + pothos-plugin-effect: + specifier: workspace:^ + version: link:../.. + devDependencies: + tsx: + specifier: ^3.12.7 + version: 3.12.7 + + examples/02-with-errors-plugin: + dependencies: + '@pothos/core': + specifier: ^3.30.0 + version: 3.30.0(graphql@16.6.0) + '@pothos/plugin-errors': + specifier: ^3.11.1 + version: 3.11.1(@pothos/core@3.30.0)(graphql@16.6.0) + graphql: + specifier: ^16.6.0 + version: 16.6.0 + graphql-yoga: + specifier: ^4.0.1 + version: 4.0.1(graphql@16.6.0) + pothos-plugin-effect: + specifier: workspace:^ + version: link:../.. devDependencies: tsx: specifier: ^3.12.7 @@ -1292,6 +1339,15 @@ packages: dependencies: graphql: 16.6.0 + /@pothos/plugin-errors@3.11.1(@pothos/core@3.30.0)(graphql@16.6.0): + resolution: {integrity: sha512-ty+L1yfGartuuIMszGZyDQtzwpt/7ijVbnmGfOZdcQZOlk1P0MUr1iJ56Z5QhaHwZuwyVBF17gwP7ZroGs9htg==} + peerDependencies: + '@pothos/core': '*' + graphql: '>=15.1.0' + dependencies: + '@pothos/core': 3.30.0(graphql@16.6.0) + graphql: 16.6.0 + /@repeaterjs/repeater@3.0.4: resolution: {integrity: sha512-AW8PKd6iX3vAZ0vA43nOUOnbq/X5ihgU+mSXXqunMkeQADGiqw/PY0JNeYtD5sr0PAy51YPgAPbDoeapv9r8WA==} diff --git a/src/field-builder.ts b/src/field-builder.ts index 49d48fd..c05f241 100644 --- a/src/field-builder.ts +++ b/src/field-builder.ts @@ -16,7 +16,7 @@ fieldBuilderProto.effect = function effect({ effect = {}, resolve, ...options }) resolve: (async (_parent: any, _args: any, _context: any, _info: GraphQLResolveInfo) => { const effectOptions = this.builder.options.effectOptions; - return pipe( + const result = await pipe( Effect.Do(), Effect.bind('context', () => { return pipe( @@ -38,9 +38,19 @@ fieldBuilderProto.effect = function effect({ effect = {}, resolve, ...options }) Effect.provideContext(context), ); }), - Effect.runPromise, + Effect.runPromiseExit, ); + if (result._tag === 'Success') { + return result.value; + } + + if (result.cause._tag === 'Annotated' && result.cause.cause._tag === 'Fail') { + throw result.cause.cause.error; + } + + throw result.cause; + function getGlobalContextFromBuilderOptions(): Effect.Effect> { return pipe( Effect.gen(function*(_) { diff --git a/src/global-types.ts b/src/global-types.ts index 0521d93..bddd34c 100644 --- a/src/global-types.ts +++ b/src/global-types.ts @@ -41,6 +41,7 @@ declare global { ServiceEntriesShape extends readonly [...EffectPluginTypes.ServiceEntry[]], ContextsShape extends readonly [...EffectPluginTypes.Context[]], LayersShape extends readonly [...EffectPluginTypes.Layer[]], + ErrorsShape extends readonly [...any[]], >( options: EffectPluginTypes.FieldOptions< // Pothos Types: @@ -52,7 +53,8 @@ declare global { // Effect Types: ServiceEntriesShape, ContextsShape, - LayersShape + LayersShape, + ErrorsShape >, ) => FieldRef; } diff --git a/src/types.ts b/src/types.ts index 573439b..f1b2e79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ import type { InputFieldMap, InputShapeFromFields, OutputShape, + PluginName, SchemaTypes, TypeParam, } from '@pothos/core'; @@ -69,6 +70,10 @@ type GetEffectRequirements< | Infer.Context | Infer.Layer; +type GetEffectErrors = 'errors' extends PluginName + ? keyof { [K in Errors[number] as InstanceType]: true } + : never; + export type FieldOptions< // Pothos Types: Types extends SchemaTypes, @@ -80,6 +85,7 @@ export type FieldOptions< ServiceEntriesShape extends readonly [...ServiceEntry[]], ContextsShape extends readonly [...Context[]], LayersShape extends readonly [...Layer[]], + ErrorsShape extends readonly [...any[]], // Pothos Types: Kind extends FieldKind = FieldKind, > = @@ -102,6 +108,7 @@ export type FieldOptions< layers?: LayersShape; services?: ServiceEntriesShape; }; + errors?: 'errors' extends PluginName ? { types: ErrorsShape } : never; resolve( parent: ParentShape, args: InputShapeFromFields, @@ -114,7 +121,7 @@ export type FieldOptions< ContextsShape, LayersShape >, - never, + GetEffectErrors, OutputShape >; };