Skip to content

Commit

Permalink
Allow non-subscription fields to return async iterable (needed for @s…
Browse files Browse the repository at this point in the history
  • Loading branch information
captbaritone committed Mar 10, 2024
1 parent 70622ae commit 84cd028
Show file tree
Hide file tree
Showing 35 changed files with 754 additions and 255 deletions.
8 changes: 4 additions & 4 deletions src/Errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ export function wrapperMissingTypeArg() {
return `Expected wrapper type reference to have type arguments. Grats needs to be able to see the return type in order to generate a GraphQL schema.`;
}

export function invalidWrapperOnInputType(wrapperName: string) {
return `Invalid input type. \`${wrapperName}\` is not a valid type when used as a GraphQL input value.`;
}

export function cannotResolveSymbolForDescription() {
return "Expected TypeScript to be able to resolve this GraphQL entity to a symbol. Is it possible that this type is not defined in this file? Grats needs to follow type references to their declaration in order to determine which GraphQL name is being referenced.";
}
Expand Down Expand Up @@ -372,10 +376,6 @@ export function subscriptionFieldNotAsyncIterable() {
return "Expected fields on `Subscription` to return an `AsyncIterable`. Fields on `Subscription` model a subscription, which is a stream of events. Grats expects fields on `Subscription` to return an `AsyncIterable` which can be used to model this stream.";
}

export function nonSubscriptionFieldAsyncIterable() {
return "Unexpected AsyncIterable. Only fields on `Subscription` should return an `AsyncIterable`. Non-subscription fields are only expected to return a single value.";
}

export function operationTypeNotUnknown() {
return "Operation types `Query`, `Mutation`, and `Subscription` must be defined as type aliases of `unknown`. E.g. `type Query = unknown`. This is because GraphQL servers do not have an agreed upon way to produce root values, and Grats errs on the side of safety. If you are trying to implement dependency injection, consider using the `context` argument passed to each resolver instead. If you have a strong use case for a concrete root value, please file an issue.";
}
Expand Down
121 changes: 52 additions & 69 deletions src/Extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ export type ExtractionSnapshot = {
readonly interfaceDeclarations: Array<ts.InterfaceDeclaration>;
};

type FieldTypeContext = {
kind: "OUTPUT" | "INPUT";
};

/**
* Extracts GraphQL definitions from TypeScript source code.
*
Expand Down Expand Up @@ -349,9 +353,8 @@ class Extractor {
return this.report(funcName, E.invalidReturnTypeForFunctionField());
}

const returnType = this.collectReturnType(node.type);
if (returnType == null) return null;
const { type, asyncIterable } = returnType;
const type = this.collectType(node.type, { kind: "OUTPUT" });
if (type == null) return null;

let args: readonly InputValueDefinitionNode[] | null = null;
const argsParam = node.parameters[1];
Expand All @@ -378,7 +381,6 @@ class Extractor {
tsModulePath,
name: funcName.text,
argCount: node.parameters.length,
asyncIterable: asyncIterable,
}),
];

Expand Down Expand Up @@ -549,7 +551,7 @@ class Extractor {
return this.report(node, E.inputFieldUntyped());
}

const inner = this.collectType(node.type);
const inner = this.collectType(node.type, { kind: "INPUT" });
if (inner == null) return null;

const type =
Expand Down Expand Up @@ -943,11 +945,10 @@ class Extractor {
name: id.text == name.value ? null : id.text,
tsModulePath: null,
argCount: null,
asyncIterable: null,
}),
];

const type = this.collectType(node.type);
const type = this.collectType(node.type, { kind: "OUTPUT" });
if (type == null) return null;

const deprecated = this.collectDeprecated(node);
Expand Down Expand Up @@ -1129,7 +1130,7 @@ class Extractor {
if (node.type == null) {
return this.report(node.name, E.argNotTyped());
}
let type = this.collectType(node.type);
let type = this.collectType(node.type, { kind: "INPUT" });
if (type == null) return null;

if (type.kind !== Kind.NON_NULL_TYPE && !node.questionToken) {
Expand Down Expand Up @@ -1409,9 +1410,8 @@ class Extractor {
return this.report(node.name, E.methodMissingType());
}

const returnType = this.collectReturnType(node.type);
if (returnType == null) return null;
const { type, asyncIterable } = returnType;
const type = this.collectType(node.type, { kind: "OUTPUT" });
if (type == null) return null;

// We already reported an error
if (type == null) return null;
Expand All @@ -1436,7 +1436,6 @@ class Extractor {
name: id.text === name.value ? null : id.text,
tsModulePath: null,
argCount: isCallable(node) ? node.parameters.length : null,
asyncIterable: asyncIterable,
}),
];

Expand All @@ -1462,51 +1461,6 @@ class Extractor {
);
}

collectReturnType(
node: ts.TypeNode,
): { type: TypeNode; asyncIterable?: ts.Node } | null {
if (ts.isTypeReferenceNode(node)) {
const identifier = this.expectNameIdentifier(node.typeName);
if (identifier == null) return null;
if (identifier.text == "AsyncIterable") {
if (node.typeArguments == null || node.typeArguments.length === 0) {
// TODO: Better error?
return this.report(node, E.wrapperMissingTypeArg());
}
const t = this.collectType(node.typeArguments[0]);
if (t == null) return null;
return { type: t, asyncIterable: identifier };
}
}
const inner = this.maybeUnwrapPromise(node);
if (inner == null) return null;
const t = this.collectType(inner);
if (t == null) return null;
return { type: t };
}

collectPropertyType(node: ts.TypeNode): TypeNode | null {
// TODO: Handle function types here.
const inner = this.maybeUnwrapPromise(node);
if (inner == null) return null;
return this.collectType(inner);
}

maybeUnwrapPromise(node: ts.TypeNode): ts.TypeNode | null {
if (ts.isTypeReferenceNode(node)) {
const identifier = this.expectNameIdentifier(node.typeName);
if (identifier == null) return null;

if (identifier.text === "Promise") {
if (node.typeArguments == null || node.typeArguments.length === 0) {
return this.report(node, E.wrapperMissingTypeArg());
}
return node.typeArguments[0];
}
}
return node;
}

collectDescription(node: ts.Node): StringValueNode | null {
const docs: readonly (ts.JSDoc | ts.JSDocTag)[] =
// @ts-ignore Exposed as stable in https://github.com/microsoft/TypeScript/pull/53627
Expand Down Expand Up @@ -1560,7 +1514,7 @@ class Extractor {
return null;
}

const inner = this.collectPropertyType(node.type);
const inner = this.collectType(node.type, { kind: "OUTPUT" });
// We already reported an error
if (inner == null) return null;
const type =
Expand All @@ -1582,7 +1536,6 @@ class Extractor {
name: id.text === name.value ? null : id.text,
tsModulePath: null,
argCount: null,
asyncIterable: null,
}),
);

Expand All @@ -1602,16 +1555,15 @@ class Extractor {
description,
);
}

// TODO: Support separate modes for input and output types
// For input nodes and field may only be optional if `null` is a valid value.
collectType(node: ts.TypeNode): TypeNode | null {
collectType(node: ts.TypeNode, ctx: FieldTypeContext): TypeNode | null {
if (ts.isTypeReferenceNode(node)) {
const type = this.typeReference(node);
const type = this.typeReference(node, ctx);
if (type == null) return null;
return type;
} else if (ts.isArrayTypeNode(node)) {
const element = this.collectType(node.elementType);
const element = this.collectType(node.elementType, ctx);
if (element == null) return null;
return this.gql.nonNullType(node, this.gql.listType(node, element));
} else if (ts.isUnionTypeNode(node)) {
Expand All @@ -1620,7 +1572,7 @@ class Extractor {
return this.report(node, E.expectedOneNonNullishType());
}

const type = this.collectType(types[0]);
const type = this.collectType(types[0], ctx);
if (type == null) return null;

if (types.length > 1) {
Expand All @@ -1637,7 +1589,7 @@ class Extractor {
}
return this.gql.nonNullType(node, type);
} else if (ts.isParenthesizedTypeNode(node)) {
return this.collectType(node.type);
return this.collectType(node.type, ctx);
} else if (node.kind === ts.SyntaxKind.StringKeyword) {
return this.gql.nonNullType(node, this.gql.namedType(node, "String"));
} else if (node.kind === ts.SyntaxKind.BooleanKeyword) {
Expand All @@ -1652,21 +1604,52 @@ class Extractor {
return null;
}

typeReference(node: ts.TypeReferenceNode): TypeNode | null {
typeReference(
node: ts.TypeReferenceNode,
ctx: FieldTypeContext,
): TypeNode | null {
const identifier = this.expectNameIdentifier(node.typeName);
if (identifier == null) return null;

const typeName = identifier.text;
// Some types are not valid as input types. Validate that here:
if (ctx.kind === "INPUT") {
switch (typeName) {
case "AsyncIterable":
return this.report(
node,
"`AsyncIterable` is not a valid as an input type.",
);
case "Promise":
return this.report(
node,
"`Promise` is not a valid as an input type.",
);
}
}
switch (typeName) {
case "Array":
case "Iterator":
case "ReadonlyArray": {
case "ReadonlyArray":
case "AsyncIterable": {
if (node.typeArguments == null) {
return this.report(node, E.pluralTypeMissingParameter());
}
const element = this.collectType(node.typeArguments[0]);
const element = this.collectType(node.typeArguments[0], ctx);
if (element == null) return null;
const listType = this.gql.listType(node, element);
if (typeName === "AsyncIterable") {
listType.isAsyncIterable = true;
}
return this.gql.nonNullType(node, listType);
}
case "Promise": {
if (node.typeArguments == null) {
return this.report(node, E.wrapperMissingTypeArg());
}
const element = this.collectType(node.typeArguments[0], ctx);
if (element == null) return null;
return this.gql.nonNullType(node, this.gql.listType(node, element));
return element;
}
default: {
// We may not have encountered the definition of this type yet. So, we
Expand Down
12 changes: 1 addition & 11 deletions src/GraphQLConstructor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,6 @@ import {
TS_MODULE_PATH_ARG,
FIELD_NAME_ARG,
ARG_COUNT,
ASYNC_ITERABLE_ARG,
FIELD_METADATA_DIRECTIVE,
} from "./metadataDirectives";

Expand All @@ -64,7 +63,6 @@ export class GraphQLConstructor {
tsModulePath: string | null;
name: string | null;
argCount: number | null;
asyncIterable?: ts.Node | null;
},
): ConstDirectiveNode {
const args: ConstArgumentNode[] = [];
Expand Down Expand Up @@ -95,15 +93,7 @@ export class GraphQLConstructor {
),
);
}
if (metadata.asyncIterable) {
args.push(
this.constArgument(
metadata.asyncIterable,
this.name(node, ASYNC_ITERABLE_ARG),
this.boolean(node, true),
),
);
}

return this.constDirective(
node,
this.name(node, FIELD_METADATA_DIRECTIVE),
Expand Down
5 changes: 3 additions & 2 deletions src/codegen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,8 +557,9 @@ class Codegen {
if (metadataDirective == null) {
throw new Error(`Expected to find metadata directive.`);
}
const metadata = parseFieldMetadataDirective(metadataDirective);
if (!metadata.asyncIterable) {
// Note: We assume the default name is used here. When custom operation types are supported
// we'll need to update this.
if (parentTypeName !== "Subscription") {
const resolve = this.resolveMethod(field, "resolve", parentTypeName);
return [this.maybeApplySemanticNullRuntimeCheck(field, resolve)];
}
Expand Down
8 changes: 4 additions & 4 deletions src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@ export function extractSchemaAndDoc(
.map((definitions) => ({ kind: Kind.DOCUMENT, definitions } as const))
// Filter out any `implements` clauses that are not GraphQL interfaces.
.map((doc) => filterNonGqlInterfaces(ctx, doc))
// Apply default nullability to fields and arguments, and detect any misuse of
// `@killsParentOnException`.
.andThen((doc) => applyDefaultNullability(doc, config))
// Resolve TypeScript type references to the GraphQL types they represent (or error).
.andThen((doc) => resolveTypes(ctx, doc))
// Ensure all subscription fields, and _only_ subscription fields, return an AsyncIterable.
// Ensure all subscription fields return an AsyncIterable.
.andThen((doc) => validateAsyncIterable(doc))
// Apply default nullability to fields and arguments, and detect any misuse of
// `@killsParentOnException`.
.andThen((doc) => applyDefaultNullability(doc, config))
// Merge any `extend` definitions into their base definitions.
.map((doc) => mergeExtensions(doc))
// Sort the definitions in the document to ensure a stable output.
Expand Down

0 comments on commit 84cd028

Please sign in to comment.