diff --git a/__tests__/starwars/client.test.ts b/__tests__/starwars/client.test.ts new file mode 100644 index 0000000..ebd644d --- /dev/null +++ b/__tests__/starwars/client.test.ts @@ -0,0 +1,15 @@ +import { Executor } from "../../src"; +import { Episode, Starwars } from "./starwars.sdk"; + +describe("Starwars SDK Client", () => { + const starwars = new Starwars(new Executor("http://localhost:8080")); + + it.skip("returns type-safe results", async () => { + const reviews = await starwars.query.reviews( + { episode: Episode.EMPIRE }, + (t) => [t.stars(), t.commentary()] + ); + + expect(reviews?.map((t) => t)).toBeInstanceOf(Array); + }); +}); diff --git a/__tests__/starwars/starwars.sdk.ts b/__tests__/starwars/starwars.sdk.ts index 645a5a2..43e493c 100644 --- a/__tests__/starwars/starwars.sdk.ts +++ b/__tests__/starwars/starwars.sdk.ts @@ -10,6 +10,7 @@ import { Executor, Client, TypeConditionError, + ExecutionError, } from "../../src"; export const VERSION = "unversioned"; @@ -957,126 +958,236 @@ export class Starwars implements Client { constructor(public readonly executor: Executor) {} public readonly query = { - hero: >( + hero: async >( variables: { episode?: Episode }, select: (t: CharacterSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "hero", - "query", - new SelectionSet([Query.hero(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "hero", + "query", + new SelectionSet([Query.hero(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "hero", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "hero", result }); + } else if (result.data) { + return result.data.hero; + } else { + throw new ExecutionError({ name: "hero", result }); + } + }, - reviews: >( + reviews: async >( variables: { episode?: Episode }, select: (t: ReviewSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "reviews", - "query", - new SelectionSet([Query.reviews(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "reviews", + "query", + new SelectionSet([Query.reviews(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "reviews", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "reviews", result }); + } else if (result.data) { + return result.data.reviews; + } else { + throw new ExecutionError({ name: "reviews", result }); + } + }, - search: >( + search: async >( variables: { text?: string }, select: (t: SearchResultSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "search", - "query", - new SelectionSet([Query.search(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "search", + "query", + new SelectionSet([Query.search(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "search", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "search", result }); + } else if (result.data) { + return result.data.search; + } else { + throw new ExecutionError({ name: "search", result }); + } + }, - character: >( + character: async >( variables: { id?: string }, select: (t: CharacterSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "character", - "query", - new SelectionSet([Query.character(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "character", + "query", + new SelectionSet([Query.character(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ + name: "character", + transportError: error, + }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "character", result }); + } else if (result.data) { + return result.data.character; + } else { + throw new ExecutionError({ name: "character", result }); + } + }, - droid: >( + droid: async >( variables: { id?: string }, select: (t: DroidSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "droid", - "query", - new SelectionSet([Query.droid(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "droid", + "query", + new SelectionSet([Query.droid(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "droid", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "droid", result }); + } else if (result.data) { + return result.data.droid; + } else { + throw new ExecutionError({ name: "droid", result }); + } + }, - human: >( + human: async >( variables: { id?: string }, select: (t: HumanSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "human", - "query", - new SelectionSet([Query.human(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "human", + "query", + new SelectionSet([Query.human(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "human", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "human", result }); + } else if (result.data) { + return result.data.human; + } else { + throw new ExecutionError({ name: "human", result }); + } + }, - starship: >( + starship: async >( variables: { id?: string }, select: (t: StarshipSelector) => T - ) => - this.executor.execute< - IQuery, - Operation>]>> - >( - new Operation( - "starship", - "query", - new SelectionSet([Query.starship(variables, select)]) + ) => { + const result = await this.executor + .execute< + IQuery, + Operation>]>> + >( + new Operation( + "starship", + "query", + new SelectionSet([Query.starship(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ name: "starship", transportError: error }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "starship", result }); + } else if (result.data) { + return result.data.starship; + } else { + throw new ExecutionError({ name: "starship", result }); + } + }, }; public readonly mutate = { - createReview: >( + createReview: async >( variables: { episode?: Episode; review?: ReviewInput }, select: (t: ReviewSelector) => T - ) => - this.executor.execute< - IMutation, - Operation>]>> - >( - new Operation( - "createReview", - "mutation", - new SelectionSet([Mutation.createReview(variables, select)]) + ) => { + const result = await this.executor + .execute< + IMutation, + Operation>]>> + >( + new Operation( + "createReview", + "mutation", + new SelectionSet([Mutation.createReview(variables, select)]) + ) ) - ), + .catch((error: any) => { + throw new ExecutionError({ + name: "createReview", + transportError: error, + }); + }); + + if (result.errors) { + throw new ExecutionError({ name: "createReview", result }); + } else if (result.data) { + return result.data.createReview; + } else { + throw new ExecutionError({ name: "createReview", result }); + } + }, }; } diff --git a/src/Client.ts b/src/Client.ts index d55d856..e28a853 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -19,14 +19,6 @@ export class Executor { } } -// @bug TypeScript 4.2+ error when implementing this interface with HTTPExecutor -// https://github.com/microsoft/TypeScript/issues/34933 -// export interface Executor { -// execute>>( -// operation: TOperation -// ): Promise>>; -// } - export const httpExecute = < RootType, TOperation extends Operation> @@ -46,3 +38,30 @@ export const httpExecute = < }).then((res) => res.json()) as Promise< ExecutionResult> >; + +export class ExecutionError extends Error { + public readonly name: string; + public readonly result?: ExecutionResult; + public readonly transportError?: Error; + + constructor({ + name, + result, + transportError, + }: { + readonly name: string; + readonly result?: ExecutionResult; + readonly transportError?: Error; + }) { + super( + `Failed to execute operation on "${name}". See "ExecutionError.what" for more details.` + ); + + this.result = result; + this.transportError = transportError; + } + + get what() { + return this.transportError ?? this.result; + } +} diff --git a/src/Codegen.ts b/src/Codegen.ts index 9a8c4c0..693b0a1 100644 --- a/src/Codegen.ts +++ b/src/Codegen.ts @@ -52,6 +52,7 @@ export class Codegen { Executor, Client, TypeConditionError, + ExecutionError, } from '${this.modulePath}' `, ]; @@ -640,6 +641,16 @@ const toPrimitive = ( } }; +const unwrapResult = (fieldName: string) => ` + if (result.errors) { + throw new ExecutionError({ name: '${fieldName}', result }) + } else if (result.data) { + return result.data.${fieldName} + } else { + throw new ExecutionError({ name: '${fieldName}', result }) + } +`; + const renderClientRootField = ( rootOp: "query" | "mutation" | "subscription", rootType: string, @@ -668,26 +679,35 @@ const renderClientRootField = ( return field.args.length > 0 ? ` ${jsDocComment} - ${field.name}: ( + ${field.name}: async ( variables: { ${field.args.map(renderVariables).join(", ")} }, - ) => this.executor.execute { + const result = await this.executor.execute ]>>>( - new Operation( - "${field.name}", - "${rootOp}", - new SelectionSet([ - ${rootType}.${field.name}( - variables, - ), - ]), - ), - ), + new Operation( + "${field.name}", + "${rootOp}", + new SelectionSet([ + ${rootType}.${field.name}( + variables, + ), + ]), + ), + ).catch((error: any) => { + throw new ExecutionError({ name: '${ + field.name + }', transportError: error }) + }) + + ${unwrapResult(field.name)} + }, ` : ` ${jsDocComment} - ${field.name}: ( - ) => this.executor.execute { + const result = await this.executor.execute ]>>>( new Operation( @@ -697,16 +717,24 @@ const renderClientRootField = ( ${rootType}.${field.name}(), ]), ) - ), + ).catch((error: any) => { + throw new ExecutionError({ name: '${ + field.name + }', transportError: error }) + }) + + ${unwrapResult(field.name)} + }, `; } else { return field.args.length > 0 ? ` ${jsDocComment} - ${field.name}: >( + ${field.name}: async >( variables: { ${field.args.map(renderVariables).join(", ")} }, select: (t: ${baseType.toString()}Selector) => T - ) => this.executor.execute { + const result = await this.executor.execute> ]>>>( new Operation( @@ -719,13 +747,19 @@ const renderClientRootField = ( ), ]), ), - ), + ).catch((error: any) => { + throw new ExecutionError({ name: '${field.name}', transportError: error }) + }) + + ${unwrapResult(field.name)} + }, ` : ` ${jsDocComment} - ${field.name}: >( + ${field.name}: async >( select: (t: ${baseType.toString()}Selector) => T - ) => this.executor.execute { + const result = await this.executor.execute> ]>>>( new Operation( @@ -735,7 +769,14 @@ const renderClientRootField = ( ${rootType}.${field.name}(select), ]), ) - ), + ).catch((error: any) => { + throw new ExecutionError({ name: '${ + field.name + }', transportError: error }) + }) + + ${unwrapResult(field.name)} + }, `; } };