Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate that all context types match #29

Merged
merged 5 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe premature to add this since we don't yet typecheck the info argument.

- [ ] 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?
Expand Down
28 changes: 27 additions & 1 deletion src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
}

Expand Down Expand Up @@ -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?";
}
70 changes: 67 additions & 3 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated typo fix.

} else if (ts.isArrayLiteralExpression(node)) {
return this.collectArrayLiteral(node);
}
Expand Down Expand Up @@ -1010,7 +1015,7 @@ export class Extractor {
return this.gql.list(node, values);
}

cellectObjectLiteral(
collectObjectLiteral(
node: ts.ObjectLiteralExpression,
): ConstObjectValueNode | null {
const fields: ConstObjectFieldNode[] = [];
Expand Down Expand Up @@ -1301,6 +1306,60 @@ 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 (!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 {
Expand All @@ -1325,6 +1384,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);
Expand Down Expand Up @@ -1556,7 +1620,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 {
Expand Down
17 changes: 17 additions & 0 deletions src/TypeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -62,6 +76,9 @@ export class TypeContext {
_options: ts.ParsedCommandLine;
_symbolToName: Map<ts.Symbol, NameDefinition> = new Map();
_unresolvedTypes: Map<NameNode, ts.Symbol> = new Map();
// The resolver context declaration, if it has been encountered.
// Gets mutated by Extractor.
gqlContext: GqlContext | null = null;
hasTypename: Set<string> = new Set();

constructor(
Expand Down
4 changes: 2 additions & 2 deletions src/tests/fixtures/arguments/NoArgsWithNever.ts
Original file line number Diff line number Diff line change
@@ -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!";
}
}
4 changes: 2 additions & 2 deletions src/tests/fixtures/arguments/NoArgsWithNever.ts.expected
Original file line number Diff line number Diff line change
Expand Up @@ -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!";
}
}
Expand Down
11 changes: 11 additions & 0 deletions src/tests/fixtures/resolver_context/ClassMethodWithContextValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type GratsContext = {
greeting: string;
};

/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, ctx: GratsContext): string {
return ctx.greeting;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-----------------
INPUT
-----------------
type GratsContext = {
greeting: string;
};

/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type GratsContext = {
greeting: string;
};

/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, ctx: GratsContext): string {
return ctx.greeting;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
-----------------
INPUT
-----------------
export type GratsContext = {
greeting: string;
};

/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type GratsContext = {
greeting: string;
};

/** @gqlType */
export class Query {
/** @gqlField */
greeting(ctx: GratsContext): string {
return ctx.greeting;
}
}
Original file line number Diff line number Diff line change
@@ -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}`.

8 greeting(ctx: GratsContext): string {
~~~~~~~~~~~~
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, ctx): string {
return ctx.greeting;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-----------------
INPUT
-----------------
/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, ctx): string {
return ctx.greeting;
}
}

-----------------
OUTPUT
-----------------
src/tests/fixtures/resolver_context/ContextValueMissingTypeAnnotation.invalid.ts:4:25 - 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: never, ctx): string {
~~~
12 changes: 12 additions & 0 deletions src/tests/fixtures/resolver_context/ContextValueOptional.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @gqlType */
export class Query {
/** @gqlField */
greeting(args: never, 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 };
Loading
Loading