diff --git a/TODO.md b/TODO.md index 79a89d30..9ddd4aba 100644 --- a/TODO.md +++ b/TODO.md @@ -50,7 +50,6 @@ - [ ] Improve playground - Could we actually evaluate the resolvers? Maybe in a worker? - Could we hook up GraphiQL 2? -- [ ] Can we ensure the context and ast arguments of resolvers are correct? - [ ] Can we use TypeScript's inference to infer types? - [ ] For example, a method which returns a string, or a property that has a default value. - [ ] Define resolvers? diff --git a/src/Errors.ts b/src/Errors.ts index 85812088..3144ec23 100644 --- a/src/Errors.ts +++ b/src/Errors.ts @@ -161,11 +161,11 @@ export function typeNameDoesNotMatchExpected(expected: string) { } export function argumentParamIsMissingType() { - return "Expected GraphQL field arguments to have a TypeScript type. If there are no arguments, you can use `args: never`."; + return "Expected GraphQL field arguments to have a TypeScript type. If there are no arguments, you can use `args: unknown`."; } export function argumentParamIsNotObject() { - return "Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`."; + return "Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`. If there are no arguments, you can use `args: unknown`."; } export function argIsNotProperty() { @@ -248,7 +248,7 @@ export function pluralTypeMissingParameter() { return `Expected type reference to have type arguments.`; } -export function expectedIdentifer() { +export function expectedIdentifier() { return "Expected an identifier."; } @@ -323,3 +323,29 @@ export function invalidTypePassedToFieldFunction() { export function unresolvedTypeReference() { return "This type is not a valid GraphQL type. Did you mean to annotate it's definition with a `/** @gql */` tag such as `/** @gqlType */` or `/** @gqlInput **/`?"; } + +export function expectedTypeAnnotationOnContext() { + return "Expected context parameter to have a type annotation. Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration."; +} + +export function expectedTypeAnnotationOfReferenceOnContext() { + return "Expected context parameter's type to be a type reference Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration."; +} + +export function expectedTypeAnnotationOnContextToBeResolvable() { + // TODO: Provide guidance? + // TODO: I don't think we have a test case that triggers this error. + return "Unable to resolve context parameter type. Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration."; +} + +export function expectedTypeAnnotationOnContextToHaveDeclaration() { + return "Unable to locate the declaration of the context parameter's type. Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. Did you forget to import or define this type?"; +} + +export function unexpectedParamSpreadForContextParam() { + return "Unexpected spread parameter in context parameter position. Grats expects the context parameter to be a single, explicitly typed, argument."; +} + +export function multipleContextTypes() { + return "Context argument's type does not match. Grats expects all resolvers that read the context argument to use the same type for that argument. Did you use the incorrect type in one of your resolvers?"; +} diff --git a/src/Extractor.ts b/src/Extractor.ts index 38263875..b270060f 100644 --- a/src/Extractor.ts +++ b/src/Extractor.ts @@ -358,6 +358,11 @@ export class Extractor { args = this.collectArgs(argsParam); } + const context = node.parameters[2]; + if (context != null) { + this.validateContextParameter(context); + } + const description = this.collectDescription(funcName); if (!ts.isSourceFile(node.parent)) { @@ -931,7 +936,7 @@ export class Extractor { if (argsType == null) { return this.report(argsParam, E.argumentParamIsMissingType()); } - if (argsType.kind === ts.SyntaxKind.NeverKeyword) { + if (argsType.kind === ts.SyntaxKind.UnknownKeyword) { return []; } if (!ts.isTypeLiteralNode(argsType)) { @@ -980,7 +985,7 @@ export class Extractor { } else if (node.kind === ts.SyntaxKind.FalseKeyword) { return this.gql.boolean(node, false); } else if (ts.isObjectLiteralExpression(node)) { - return this.cellectObjectLiteral(node); + return this.collectObjectLiteral(node); } else if (ts.isArrayLiteralExpression(node)) { return this.collectArrayLiteral(node); } @@ -1010,7 +1015,7 @@ export class Extractor { return this.gql.list(node, values); } - cellectObjectLiteral( + collectObjectLiteral( node: ts.ObjectLiteralExpression, ): ConstObjectValueNode | null { const fields: ConstObjectFieldNode[] = []; @@ -1301,6 +1306,66 @@ export class Extractor { return this.gql.name(id, id.text); } + // Ensure the type of the ctx param resolves to the declaration + // annotated with `@gqlContext`. + validateContextParameter(node: ts.ParameterDeclaration) { + if (node.type == null) { + return this.report(node, E.expectedTypeAnnotationOnContext()); + } + + if (node.type.kind === ts.SyntaxKind.UnknownKeyword) { + // If the user just needs to define the argument to get to a later parameter, + // they can use `ctx: unknown` to safely avoid triggering a Grats error. + return; + } + + if (!ts.isTypeReferenceNode(node.type)) { + return this.report( + node.type, + E.expectedTypeAnnotationOfReferenceOnContext(), + ); + } + + // Check for ... + if (node.dotDotDotToken != null) { + return this.report( + node.dotDotDotToken, + E.unexpectedParamSpreadForContextParam(), + ); + } + + const symbol = this.ctx.checker.getSymbolAtLocation(node.type.typeName); + if (symbol == null) { + return this.report( + node.type.typeName, + E.expectedTypeAnnotationOnContextToBeResolvable(), + ); + } + + const declaration = this.ctx.findSymbolDeclaration(symbol); + if (declaration == null) { + return this.report( + node.type.typeName, + E.expectedTypeAnnotationOnContextToHaveDeclaration(), + ); + } + + if (this.ctx.gqlContext == null) { + // This is the first typed context value we've seen... + this.ctx.gqlContext = { + declaration: declaration, + firstReference: node.type.typeName, + }; + } else if (this.ctx.gqlContext.declaration !== declaration) { + return this.report(node.type.typeName, E.multipleContextTypes(), [ + this.related( + this.ctx.gqlContext.firstReference, + "A different type reference was used here", + ), + ]); + } + } + methodDeclaration( node: ts.MethodDeclaration | ts.MethodSignature, ): FieldDefinitionNode | null { @@ -1325,6 +1390,11 @@ export class Extractor { args = this.collectArgs(argsParam); } + const context = node.parameters[1]; + if (context != null) { + this.validateContextParameter(context); + } + const description = this.collectDescription(node.name); const id = this.expectIdentifier(node.name); @@ -1556,7 +1626,7 @@ export class Extractor { if (ts.isIdentifier(node)) { return node; } - return this.report(node, E.expectedIdentifer()); + return this.report(node, E.expectedIdentifier()); } findTag(node: ts.Node, tagName: string): ts.JSDocTag | null { diff --git a/src/TypeContext.ts b/src/TypeContext.ts index 5539c688..ed720b28 100644 --- a/src/TypeContext.ts +++ b/src/TypeContext.ts @@ -43,6 +43,20 @@ export type AbstractFieldDefinitionNode = { readonly field: FieldDefinitionNode; }; +/** + * Information about the GraphQL context type. We track the first value we see, + * and then validate that any other values we see are the same. + */ +type GqlContext = { + // If we follow the context type back to its source, this is the declaration + // we find. + declaration: ts.Node; + + // The first reference to the context type that we encountered. Used for + // reporting errors if a different context type is encountered. + firstReference: ts.Node; +}; + /** * Used to track TypeScript references. * @@ -62,6 +76,9 @@ export class TypeContext { _options: ts.ParsedCommandLine; _symbolToName: Map = new Map(); _unresolvedTypes: Map = new Map(); + // The resolver context declaration, if it has been encountered. + // Gets mutated by Extractor. + gqlContext: GqlContext | null = null; hasTypename: Set = new Set(); constructor( diff --git a/src/tests/fixtures/arguments/NoArgsWithNever.ts b/src/tests/fixtures/arguments/NoArgsWithNever.ts index 203dbba7..b0d4a24a 100644 --- a/src/tests/fixtures/arguments/NoArgsWithNever.ts +++ b/src/tests/fixtures/arguments/NoArgsWithNever.ts @@ -1,8 +1,8 @@ /** @gqlType */ export default class Query { /** @gqlField */ - hello(args: never, ctx: any): string { - console.log(ctx); + hello(args: never): string { + console.log("hello"); return "Hello world!"; } } diff --git a/src/tests/fixtures/arguments/NoArgsWithNever.ts.expected b/src/tests/fixtures/arguments/NoArgsWithNever.ts.expected index 52092e9d..66516f36 100644 --- a/src/tests/fixtures/arguments/NoArgsWithNever.ts.expected +++ b/src/tests/fixtures/arguments/NoArgsWithNever.ts.expected @@ -4,8 +4,8 @@ INPUT /** @gqlType */ export default class Query { /** @gqlField */ - hello(args: never, ctx: any): string { - console.log(ctx); + hello(args: never): string { + console.log("hello"); return "Hello world!"; } } @@ -13,14 +13,7 @@ export default class Query { ----------------- OUTPUT ----------------- -schema { - query: Query -} - -directive @methodName(name: String!) on FIELD_DEFINITION - -directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION +src/tests/fixtures/arguments/NoArgsWithNever.ts:4:15 - error: Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`. If there are no arguments, you can use `args: unknown`. -type Query { - hello: String -} \ No newline at end of file +4 hello(args: never): string { + ~~~~~ diff --git a/src/tests/fixtures/arguments/NoArgsWithUnknown.ts b/src/tests/fixtures/arguments/NoArgsWithUnknown.ts new file mode 100644 index 00000000..8b6e36c2 --- /dev/null +++ b/src/tests/fixtures/arguments/NoArgsWithUnknown.ts @@ -0,0 +1,8 @@ +/** @gqlType */ +export default class Query { + /** @gqlField */ + hello(args: unknown): string { + console.log("hello"); + return "Hello world!"; + } +} diff --git a/src/tests/fixtures/arguments/NoArgsWithUnknown.ts.expected b/src/tests/fixtures/arguments/NoArgsWithUnknown.ts.expected new file mode 100644 index 00000000..1b409d18 --- /dev/null +++ b/src/tests/fixtures/arguments/NoArgsWithUnknown.ts.expected @@ -0,0 +1,26 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export default class Query { + /** @gqlField */ + hello(args: unknown): string { + console.log("hello"); + return "Hello world!"; + } +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + hello: String +} \ No newline at end of file diff --git a/src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts.expected b/src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts.expected index 1404f6a8..3db796a7 100644 --- a/src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts.expected +++ b/src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts.expected @@ -12,7 +12,7 @@ export default class Query { ----------------- OUTPUT ----------------- -src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts:4:9 - error: Expected GraphQL field arguments to have a TypeScript type. If there are no arguments, you can use `args: never`. +src/tests/fixtures/arguments/NoTypeAnnotation.invalid.ts:4:9 - error: Expected GraphQL field arguments to have a TypeScript type. If there are no arguments, you can use `args: unknown`. 4 hello(args): string { ~~~~ diff --git a/src/tests/fixtures/arguments/OpaqueArgType.invalid.ts.expected b/src/tests/fixtures/arguments/OpaqueArgType.invalid.ts.expected index da3586ff..a3cd5130 100644 --- a/src/tests/fixtures/arguments/OpaqueArgType.invalid.ts.expected +++ b/src/tests/fixtures/arguments/OpaqueArgType.invalid.ts.expected @@ -14,7 +14,7 @@ export default class Query { ----------------- OUTPUT ----------------- -src/tests/fixtures/arguments/OpaqueArgType.invalid.ts:6:23 - error: Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`. +src/tests/fixtures/arguments/OpaqueArgType.invalid.ts:6:23 - error: Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`. If there are no arguments, you can use `args: unknown`. 6 hello({ greeting }: SomeType): string { ~~~~~~~~ diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts b/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts new file mode 100644 index 00000000..93023eaf --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts @@ -0,0 +1,11 @@ +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts.expected b/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts.expected new file mode 100644 index 00000000..50b70cf3 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts.expected @@ -0,0 +1,29 @@ +----------------- +INPUT +----------------- +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts new file mode 100644 index 00000000..33cc23bf --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts @@ -0,0 +1,11 @@ +export type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts.expected b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts.expected new file mode 100644 index 00000000..05cf3db4 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueExported.ts.expected @@ -0,0 +1,29 @@ +----------------- +INPUT +----------------- +export type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts new file mode 100644 index 00000000..58d5f5c0 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts @@ -0,0 +1,11 @@ +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(ctx: GratsContext): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts.expected b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts.expected new file mode 100644 index 00000000..52581ae7 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts.expected @@ -0,0 +1,22 @@ +----------------- +INPUT +----------------- +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(ctx: GratsContext): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ClassMethodWithContextValueInArgsPos.invalid.ts:8:17 - error: Expected GraphQL field arguments to be typed using a literal object: `{someField: string}`. If there are no arguments, you can use `args: unknown`. + +8 greeting(ctx: GratsContext): string { + ~~~~~~~~~~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts b/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts new file mode 100644 index 00000000..ee788e54 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts.expected b/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts.expected new file mode 100644 index 00000000..cad7ece9 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts:4:27 - error: Expected context parameter to have a type annotation. Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. + +4 greeting(args: unknown, ctx): string { + ~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueOptional.ts b/src/tests/fixtures/resolver_context/ContextValueOptional.ts new file mode 100644 index 00000000..e5688a0e --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueOptional.ts @@ -0,0 +1,12 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx?: SomeType): string { + // This is fine since Grats will always pass ctx. It's fine for + // the resolver to _also_ work _without_ ctx, as long as it's + // safe for Grats to pass ctx. + return ctx?.greeting ?? "Hello, World!"; + } +} + +type SomeType = { greeting: string }; diff --git a/src/tests/fixtures/resolver_context/ContextValueOptional.ts.expected b/src/tests/fixtures/resolver_context/ContextValueOptional.ts.expected new file mode 100644 index 00000000..e955004a --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueOptional.ts.expected @@ -0,0 +1,30 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx?: SomeType): string { + // This is fine since Grats will always pass ctx. It's fine for + // the resolver to _also_ work _without_ ctx, as long as it's + // safe for Grats to pass ctx. + return ctx?.greeting ?? "Hello, World!"; + } +} + +type SomeType = { greeting: string }; + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts b/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts new file mode 100644 index 00000000..5d27b72d --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts @@ -0,0 +1,9 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ...ctx: SomeType): string { + return ctx[0].greeting; + } +} + +type SomeType = { greeting: string }; diff --git a/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts.expected b/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts.expected new file mode 100644 index 00000000..9c0a5376 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts.expected @@ -0,0 +1,20 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ...ctx: SomeType): string { + return ctx[0].greeting; + } +} + +type SomeType = { greeting: string }; + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueSpread.invalid.ts:4:27 - error: Unexpected spread parameter in context parameter position. Grats expects the context parameter to be a single, explicitly typed, argument. + +4 greeting(args: unknown, ...ctx: SomeType): string { + ~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts b/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts new file mode 100644 index 00000000..d74641d0 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: ThisIsNeverDefined): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts.expected new file mode 100644 index 00000000..940176e8 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: ThisIsNeverDefined): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueTypeNotDefined.ts:4:32 - error: Unable to locate the declaration of the context parameter's type. Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. Did you forget to import or define this type? + +4 greeting(args: unknown, ctx: ThisIsNeverDefined): string { + ~~~~~~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts b/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts new file mode 100644 index 00000000..283f8c0d --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: any): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts.expected new file mode 100644 index 00000000..d7301a1d --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: any): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueTypedAsAny.invalid.ts:4:32 - error: Expected context parameter's type to be a type reference Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. + +4 greeting(args: unknown, ctx: any): string { + ~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts b/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts new file mode 100644 index 00000000..5eb8b98b --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: { greeting: string }): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts.expected new file mode 100644 index 00000000..2b6e4b09 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: { greeting: string }): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueTypedAsLiteral.invalid.ts:4:32 - error: Expected context parameter's type to be a type reference Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. + +4 greeting(args: unknown, ctx: { greeting: string }): string { + ~~~~~~~~~~~~~~~~~~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts b/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts new file mode 100644 index 00000000..6cbef22a --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: never): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts.expected new file mode 100644 index 00000000..8896b90e --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: never): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueTypedAsNever.ts:4:32 - error: Expected context parameter's type to be a type reference Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. + +4 greeting(args: unknown, ctx: never): string { + ~~~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts b/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts new file mode 100644 index 00000000..46148a5d --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: string): string { + return ctx; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts.expected new file mode 100644 index 00000000..83d5bf7c --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts.expected @@ -0,0 +1,18 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: string): string { + return ctx; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/ContextValueTypedAsString.invalid.ts:4:32 - error: Expected context parameter's type to be a type reference Grats validates that your context parameter is type-safe by checking all context values reference the same type declaration. + +4 greeting(args: unknown, ctx: string): string { + ~~~~~~ diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts b/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts new file mode 100644 index 00000000..27da8846 --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts @@ -0,0 +1,7 @@ +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: unknown): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts.expected b/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts.expected new file mode 100644 index 00000000..c696a0cc --- /dev/null +++ b/src/tests/fixtures/resolver_context/ContextValueTypedAsUnknown.ts.expected @@ -0,0 +1,25 @@ +----------------- +INPUT +----------------- +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: unknown): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts b/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts new file mode 100644 index 00000000..7297070a --- /dev/null +++ b/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts @@ -0,0 +1,11 @@ +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class User {} + +/** @gqlField */ +export function greeting(_: User, args: unknown, ctx: GratsContext): string { + return ctx.greeting; +} diff --git a/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts.expected b/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts.expected new file mode 100644 index 00000000..d27f4466 --- /dev/null +++ b/src/tests/fixtures/resolver_context/FunctionWithContextValue.ts.expected @@ -0,0 +1,25 @@ +----------------- +INPUT +----------------- +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class User {} + +/** @gqlField */ +export function greeting(_: User, args: unknown, ctx: GratsContext): string { + return ctx.greeting; +} + +----------------- +OUTPUT +----------------- +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type User { + greeting: String @exported(filename: "tests/fixtures/resolver_context/FunctionWithContextValue.js", functionName: "greeting") +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts b/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts new file mode 100644 index 00000000..acd579bc --- /dev/null +++ b/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts @@ -0,0 +1,16 @@ +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } + + /** @gqlField */ + alsoGreeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts.expected b/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts.expected new file mode 100644 index 00000000..c808d8e8 --- /dev/null +++ b/src/tests/fixtures/resolver_context/MultipleClassMethodsReferencingContextValue.ts.expected @@ -0,0 +1,35 @@ +----------------- +INPUT +----------------- +type GratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } + + /** @gqlField */ + alsoGreeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +schema { + query: Query +} + +directive @methodName(name: String!) on FIELD_DEFINITION + +directive @exported(filename: String!, functionName: String!) on FIELD_DEFINITION + +type Query { + greeting: String + alsoGreeting: String +} \ No newline at end of file diff --git a/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts b/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts new file mode 100644 index 00000000..312d497b --- /dev/null +++ b/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts @@ -0,0 +1,19 @@ +type GratsContext = { + greeting: string; +}; + +type AlsoGratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } + /** @gqlField */ + alsoGreeting(args: unknown, ctx: AlsoGratsContext): string { + return ctx.greeting; + } +} diff --git a/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts.expected b/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts.expected new file mode 100644 index 00000000..c44cd4a6 --- /dev/null +++ b/src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts.expected @@ -0,0 +1,35 @@ +----------------- +INPUT +----------------- +type GratsContext = { + greeting: string; +}; + +type AlsoGratsContext = { + greeting: string; +}; + +/** @gqlType */ +export class Query { + /** @gqlField */ + greeting(args: unknown, ctx: GratsContext): string { + return ctx.greeting; + } + /** @gqlField */ + alsoGreeting(args: unknown, ctx: AlsoGratsContext): string { + return ctx.greeting; + } +} + +----------------- +OUTPUT +----------------- +src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts:16:36 - error: Context argument's type does not match. Grats expects all resolvers that read the context argument to use the same type for that argument. Did you use the incorrect type in one of your resolvers? + +16 alsoGreeting(args: unknown, ctx: AlsoGratsContext): string { + ~~~~~~~~~~~~~~~~ + + src/tests/fixtures/resolver_context/MultipleContextValuesUsed.invalid.ts:12:32 + 12 greeting(args: unknown, ctx: GratsContext): string { + ~~~~~~~~~~~~ + A different type reference was used here diff --git a/website/docs/04-dockblock-tags/01-types.mdx b/website/docs/04-dockblock-tags/01-types.mdx index 84af96a2..e7cde5a8 100644 --- a/website/docs/04-dockblock-tags/01-types.mdx +++ b/website/docs/04-dockblock-tags/01-types.mdx @@ -49,7 +49,7 @@ Like GraphQL's schema definition language, each type which implemnts of an inter ### Classes -If you are using classes to model your GraphQL resolvers, you can define your types as implementing a GraphQL interface by declaring that your class `implements` an interface which has been annotated with [`@gqlInterface`](./04-interfaces.mdx). +If you are using classes to model your GraphQL resolvers, you can define your types as implementing a GraphQL interface by declaring that your class `implements` an interface which has been annotated with [`@gqlInterface`](./05-interfaces.mdx). @@ -59,7 +59,7 @@ Will generate the following GraphQL schema: ### TypeScript Interface -If you are using interfaces to model your GraphQL resolvers, you can define your types as implementing a GraphQL interface by declaring that your class `extends` an interface which has been annotated with [`@gqlInterface`](./04-interfaces.mdx). +If you are using interfaces to model your GraphQL resolvers, you can define your types as implementing a GraphQL interface by declaring that your class `extends` an interface which has been annotated with [`@gqlInterface`](./05-interfaces.mdx). @@ -74,7 +74,7 @@ Types declared using a type alias _may not_ implement a GraphQL interface. Inste --- :::info -See [Interfaces](./04-interfaces.mdx) for more information about defining interfaces. +See [Interfaces](./05-interfaces.mdx) for more information about defining interfaces. ::: :::note diff --git a/website/docs/04-dockblock-tags/04-context.mdx b/website/docs/04-dockblock-tags/04-context.mdx new file mode 100644 index 00000000..aba8d431 --- /dev/null +++ b/website/docs/04-dockblock-tags/04-context.mdx @@ -0,0 +1,40 @@ +# Context + +After the [arguments object](./03-arguments.mdx), each resolver method/function is passed a [context value](https://graphql.org/learn/execution/#root-fields-resolvers). Context is the standard way to implement depednecy injection for GraphQL resolvers. Typically the context value will be an object includiding the current request, information about the requesting user, as well as a database connection and per-request caches, such as [DataLoaders](https://github.com/graphql/dataloader). + +Becuase GraphQL invokes resolver functions dynamically, there are generally no static calls to your resolver methods/functions that are visible to TypeScript. This means that there is no typecheck that can confirm that the context arugment is typed correctly. + +Grats helps to mitigate this risk by alidating that **every resolver that specifies a context argument references _the same defintion_ for the context value's type**. This at least ensures all your resolvers match. + +```ts +type GQLCtx = { + req: Request; + userID: string; + db: Database; +}; + +/** @gqlField */ +// highlight-start +export function me(_: Query, args: unknown, ctx: GQLCtx): User { + // highlight-end + return ctx.db.users.getById(ctx.userID); +} +``` + +The context argument is passed as the third argument of resovler _functions_ and the second argument of resolver _methods_. If your resolver does not need access to the context object, you can omit the context argument. + +:::tip +If you need to access the context object in your resolver, but your field does not define any args, you can type your args parameter as `unknown`. +::: + +:::info +Due to limitations in the TypeScript compiler, Grats is not able to [structurally](https://en.wikipedia.org/wiki/Structural_type_system) typecheck the context value. Instead is simply checks that every resolver that specifies a context argument references the same _type definition_. +::: + +## Constructing your context object + +The mechanism by which you construct your context object will vary depending upon the GraphQL server library you are using. See your GraphQL server library's documentation for more information. + +:::caution +Grats can ensure that every resolver is expecting the same context type, but it cannot ensure that the context value you construct and pass in matches that type. **It is up to you to ensure that your context value is constructed correctly and passed to the GraphQL execution engine.** +::: diff --git a/website/docs/04-dockblock-tags/04-interfaces.mdx b/website/docs/04-dockblock-tags/05-interfaces.mdx similarity index 100% rename from website/docs/04-dockblock-tags/04-interfaces.mdx rename to website/docs/04-dockblock-tags/05-interfaces.mdx diff --git a/website/docs/04-dockblock-tags/05-unions.mdx b/website/docs/04-dockblock-tags/06-unions.mdx similarity index 100% rename from website/docs/04-dockblock-tags/05-unions.mdx rename to website/docs/04-dockblock-tags/06-unions.mdx diff --git a/website/docs/04-dockblock-tags/06-scalars.mdx b/website/docs/04-dockblock-tags/08-scalars.mdx similarity index 100% rename from website/docs/04-dockblock-tags/06-scalars.mdx rename to website/docs/04-dockblock-tags/08-scalars.mdx diff --git a/website/docs/04-dockblock-tags/08-inputs.mdx b/website/docs/04-dockblock-tags/09-inputs.mdx similarity index 100% rename from website/docs/04-dockblock-tags/08-inputs.mdx rename to website/docs/04-dockblock-tags/09-inputs.mdx diff --git a/website/docs/04-dockblock-tags/index.md b/website/docs/04-dockblock-tags/index.md index 3adcb9cb..e1e1ce80 100644 --- a/website/docs/04-dockblock-tags/index.md +++ b/website/docs/04-dockblock-tags/index.md @@ -8,16 +8,17 @@ special JSDoc tags such as `/** @gqlType */` or `/** @gqlField */`. JSDocs must being with `/**` (two asterix). However, they may be consolidated into a single line `/** Like this */`. ::: -Each tag maps directly to a concept in the GraphQL [Schema Definition Language](https://graphql.org/learn/schema/) (SDL). The following JSDoc tags are supported: - -* [`@gqlType`](./01-types.mdx) -* [`@gqlInterface`](./04-interfaces.mdx) -* [`@gqlField`](./02-fields.mdx) -* [`@gqlUnion`](./05-unions.mdx) -* [`@gqlScalar`](./06-scalars.mdx) -* [`@gqlEnum`](./07-enums.mdx) -* [`@gqlInput`](./08-inputs.mdx) +Each tag maps directly to a concept in the GraphQL [Schema Definition Language](https://graphql.org/learn/schema/) (SDL). The following JSDoc tags are supported: +- [`@gqlType`](./01-types.mdx) +- [`@gqlField`](./02-fields.mdx) + - [Arguments](./03-arguments.mdx) + - [Context](./04-context.mdx) +- [`@gqlInterface`](./05-interfaces.mdx) +- [`@gqlUnion`](./06-unions.mdx) +- [`@gqlEnum`](./07-enums.mdx) +- [`@gqlScalar`](./08-scalars.mdx) +- [`@gqlInput`](./09-inputs.mdx) :::tip This documentaiton aims diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index fdd0185c..aa04eb8b 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -22,7 +22,7 @@ const config = { projectName: "grats", // Usually your repo name. onBrokenLinks: "throw", - onBrokenMarkdownLinks: "warn", + onBrokenMarkdownLinks: "throw", // Even if you don't use internalization, you can use this field to set useful // metadata like html lang. For example, if your site is Chinese, you may want diff --git a/website/src/components/PlaygroundFeatures/store.ts b/website/src/components/PlaygroundFeatures/store.ts index 4faaef57..cefd396b 100644 --- a/website/src/components/PlaygroundFeatures/store.ts +++ b/website/src/components/PlaygroundFeatures/store.ts @@ -76,8 +76,9 @@ function reducer(state: State = stateFromUrl(), action: Action) { doc: action.value, }; } - default: + default: { const _: never = action; + } } return state;