diff --git a/src/domain/javascript/javascriptRpcEndpoints.test.ts b/src/domain/javascript/javascriptRpcEndpoints.test.ts index c416c84616..b163e14443 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.test.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.test.ts @@ -297,6 +297,44 @@ describe("GraphQL operation recognition", () => { ]); }); + it.each([ + ["query", "query # header\nGetUser { user }"], + ["mutation", "mutation,# header\r\nGetUser { user }"], + ["subscription", "subscription# header\rGetUser { user }"], + ["query", "query GetUser # selection\n{ user }"], + ["query", "query GetUser { # field\nuser }"], + ["query", "query GetUser( # variable\n$id: ID!) { user }"], + ["query", "query GetUser # directive\n@skip(if: false) { user }"], + ["query", "query GetUser @ # directive name\nkeep { user }"], + [ + "query", + "# prefix\nquery # one\n, # two\nGetUser # three\n{ # four\nuser }", + ], + ])("recognizes %s header comments in %s", (type, text) => { + const expected = [ + { kind: "network", value: "GetUser", mechanism: `graphql:${type}` }, + ]; + expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual( + expected, + ); + expect(rpcEndpoints("const doc = gql`" + text + "`; ")).toEqual(expected); + }); + + it.each([ + "query # GetUser { user }", + "query GetUser # { user }", + "query GetUser { # user }", + "query # anonymous\n{ user }", + "query GetUser # prose\nparameters", + "query Get# split name\nUser { user }", + "query GetUser @ # missing directive name", + "query GetUser { # fragment\n...UserFields }", + "query\u00a0GetUser { user }", + "query\vGetUser { user }", + ])("keeps incomplete or unsupported header text unknown: %s", (text) => { + expect(rpcEndpoints(`const doc = ${JSON.stringify(text)};`)).toEqual([]); + }); + it("retains operations after long ignored prefixes and long headers", () => { const name = "Operation" + "x".repeat(512); const text = diff --git a/src/domain/javascript/javascriptRpcEndpoints.ts b/src/domain/javascript/javascriptRpcEndpoints.ts index d706c9aed4..9ad4e3cf20 100644 --- a/src/domain/javascript/javascriptRpcEndpoints.ts +++ b/src/domain/javascript/javascriptRpcEndpoints.ts @@ -154,19 +154,21 @@ const GRAPHQL_OPERATION_TYPES: ReadonlySet = new Set([ "mutation", "subscription", ]); -// A named GraphQL operation header (GraphQL October 2021, OperationDefinition): -// type, name, then variable definitions starting with `$`, a directive, or a -// selection set starting with a field name. -const GRAPHQL_OPERATION_HEADER = - /^(query|mutation|subscription)[\s,]+([_A-Za-z][_0-9A-Za-z]*)[\s,]*(?:\([\s,]*\$|@[_A-Za-z]|\{[\s,]*[_A-Za-z])/u; +// GraphQL October 2021 §2.1.7: ignored BOM, tab/space, line endings, commas; +// comments are consumed separately below. const isGraphqlIgnoredCharacter = (character: string): boolean => - character === "," || character === "" || /\s/u.test(character); + character === "," || + character === "\uFEFF" || + character === "\t" || + character === " " || + character === "\n" || + character === "\r"; /** Return the first offset after GraphQL ignored tokens. */ -const skipGraphqlIgnored = (text: string): number => { +const skipGraphqlIgnored = (text: string, start: number): number => { const limit = text.length; - let index = 0; + let index = start; while (index < limit) { const character = text.charAt(index); if (isGraphqlIgnoredCharacter(character)) index += 1; @@ -177,18 +179,37 @@ const skipGraphqlIgnored = (text: string): number => { return index; }; +const graphqlNameAt = ( + text: string, + index: number, +): { readonly value: string; readonly end: number } | undefined => { + const name = /^[_A-Za-z][_0-9A-Za-z]*/u.exec(text.slice(index))?.[0]; + return name === undefined + ? undefined + : { value: name, end: index + name.length }; +}; + /** Recover a named operation from GraphQL document text. */ export const graphqlOperationFromText = ( text: string, ): JavaScriptRpcEndpoint | undefined => { - const index = skipGraphqlIgnored(text); - if (index >= text.length) return undefined; - const header = GRAPHQL_OPERATION_HEADER.exec(text.slice(index)); - const type = header?.[1]; - const name = header?.[2]; - return type === undefined || name === undefined - ? undefined - : { value: name, mechanism: `graphql:${type}` }; + const type = graphqlNameAt(text, skipGraphqlIgnored(text, 0)); + if (type === undefined || !GRAPHQL_OPERATION_TYPES.has(type.value)) + return undefined; + const name = graphqlNameAt(text, skipGraphqlIgnored(text, type.end)); + if (name === undefined) return undefined; + const index = skipGraphqlIgnored(text, name.end); + const next = text.charAt(index); + // Keep the existing bounded recognition scope: a named operation followed + // by variable definitions, a directive, or a selection set with a field. + const hasHeader = + next === "(" + ? text.charAt(skipGraphqlIgnored(text, index + 1)) === "$" + : (next === "@" || next === "{") && + graphqlNameAt(text, skipGraphqlIgnored(text, index + 1)) !== undefined; + return hasHeader + ? { value: name.value, mechanism: `graphql:${type.value}` } + : undefined; }; /** Recover a named operation from a precompiled GraphQL document object. */ @@ -322,7 +343,7 @@ export const createJavaScriptRpcScan = ( for (const quasi of node.quasis) { const cooked = quasi.value.cooked; if (cooked === undefined || cooked === null) return undefined; - if (skipGraphqlIgnored(cooked) === cooked.length) continue; + if (skipGraphqlIgnored(cooked, 0) === cooked.length) continue; return graphqlOperationFromText(cooked); } return undefined;