import "apollo-env"; import { fs } from "./localfs"; import { stripIndents } from "common-tags"; const astTypes = require("ast-types"); const recast = require("recast"); import { buildClientSchema, Source, concatAST, parse, DocumentNode, GraphQLSchema, visit, Kind, OperationDefinitionNode, FragmentDefinitionNode } from "graphql"; import { ToolError } from "apollo-language-server"; export function loadSchema(schemaPath: string): GraphQLSchema { if (!fs.existsSync(schemaPath)) { throw new ToolError(`Cannot find GraphQL schema file: ${schemaPath}`); } const schemaData = require(schemaPath); if (!schemaData.data && !schemaData.__schema) { throw new ToolError( "GraphQL schema file should contain a valid GraphQL introspection query result" ); } return buildClientSchema(schemaData.data ? schemaData.data : schemaData); } function maybeCommentedOut(content: string) { return ( (content.indexOf("/*") > -1 && content.indexOf("*/") > -1) || content.split("//").length > 1 ); } function filterValidDocuments(documents: string[]) { return documents.filter(document => { const source = new Source(document); try { parse(source); return true; } catch (e) { if (!maybeCommentedOut(document)) { console.warn( stripIndents` Failed to parse: ${document.trim().split("\n")[0]}... ` ); } return false; } }); } function extractDocumentsWithAST( content: string, options: { tagName?: string; parser?: any; } ): string[] { let tagName = options.tagName || "gql"; // Sometimes the js is unparsable, so this function will throw const ast = recast.parse(content, { parser: options.parser || require("recast/parsers/babylon") }); const finished: string[] = []; // isolate the template literals tagged with gql astTypes.visit(ast, { visitTaggedTemplateExpression(path: any) { const tag = path.value.tag; if (tag.name === tagName) { // This currently ignores the anti-pattern of including an interpolated // string as anything other than a fragment definition, for example a // literal(these cases could be covered during the replacement of // literals in the signature calculation) finished.push( (path.value.quasi.quasis as Array<{ value: { cooked: string; raw: string }; }>) .map(({ value }) => value.cooked) .join("") ); } return this.traverse(path); } }); return finished; } export function extractDocumentFromJavascript( content: string, options: { tagName?: string; parser?: any; inputPath?: string; } = {} ): string | null { let matches: string[] = []; try { matches = extractDocumentsWithAST(content, options); } catch (e) { e.message = "Operation extraction " + (options.inputPath ? "from file " + options.inputPath + " " : "") + "failed with \n" + e.message; throw e; } matches = filterValidDocuments(matches); const doc = matches.join("\n"); return doc.length ? doc : null; } export function loadQueryDocuments( inputPaths: string[], tagName: string = "gql" ): DocumentNode[] { const sources = inputPaths .map(inputPath => { if (fs.lstatSync(inputPath).isDirectory()) { return null; } const body = fs.readFileSync(inputPath, "utf8"); if (!body) { return null; } if ( inputPath.endsWith(".jsx") || inputPath.endsWith(".js") || inputPath.endsWith(".tsx") || inputPath.endsWith(".ts") ) { let parser; if (inputPath.endsWith(".ts")) { parser = require("recast/parsers/typescript"); } else if (inputPath.endsWith(".tsx")) { parser = { parse: (source: any, options: any) => { const babelParser = require("@babel/parser"); options = require("recast/parsers/_babylon_options.js")(options); options.plugins.push("jsx", "typescript"); return babelParser.parse(source, options); } }; } else { parser = require("recast/parsers/babylon"); } const doc = extractDocumentFromJavascript(body.toString(), { tagName, parser, inputPath }); return doc ? new Source(doc, inputPath) : null; } if ( inputPath.endsWith(".graphql") || inputPath.endsWith(".graphqls") || inputPath.endsWith(".gql") ) { return new Source(body, inputPath); } return null; }) .filter(source => source) .map(source => { try { return parse(source!); } catch (e) { const name = (source && source.name) || ""; console.warn(stripIndents` Warning: error parsing GraphQL file ${name} ${e.stack}`); return null; } }) .filter(source => source); return sources as DocumentNode[]; } export function loadAndMergeQueryDocuments( inputPaths: string[], tagName: string = "gql" ): DocumentNode { return concatAST(loadQueryDocuments(inputPaths, tagName)); } export function extractOperationsAndFragments( documents: Array, errorLogger?: (message: string) => void ) { const fragments: Record = {}; const operations: Array = []; documents.forEach(operation => { // We could use separateOperations from graphql-js in the case that // all fragments are defined in the same file. Currently this // solution duplicates much of the logic, adding the ability to pull // fragments from separate files visit(operation, { [Kind.FRAGMENT_DEFINITION]: node => { if (!node.name || node.name.kind !== "Name") { (errorLogger || console.warn)( `Fragment Definition must have a name ${node}` ); } if (fragments[node.name.value]) { (errorLogger || console.warn)( `Duplicate definition of fragment ${node.name.value}. Please rename one of them or use the same fragment` ); } fragments[node.name.value] = node; }, [Kind.OPERATION_DEFINITION]: node => { operations.push(node); } }); }); return { fragments, operations }; } export function combineOperationsAndFragments( operations: Array, fragments: Record, errorLogger?: (message: string) => void ) { const fullOperations: Array = []; operations.forEach(operation => { const completeOperation: Array< OperationDefinitionNode | FragmentDefinitionNode > = [ operation, ...Object.values(getNestedFragments(operation, fragments, errorLogger)) ]; fullOperations.push({ kind: "Document", definitions: completeOperation }); }); return fullOperations; } function getNestedFragments( operation: OperationDefinitionNode | FragmentDefinitionNode, fragments: Record, errorLogger?: (message: string) => void ) { // Using an object ensures that we only include each fragment definition once. // We are assured that there will be no duplicate fragment names during the // extraction step const combination: Record = {}; visit(operation, { [Kind.FRAGMENT_SPREAD]: node => { if (!node.name || node.name.kind !== "Name") { (errorLogger || console.warn)( `Fragment Spread must have a name ${node}` ); } if (!fragments[node.name.value]) { (errorLogger || console.warn)( `Fragment ${node.name.value} is not defined. Please add the file containing the fragment to the set of included paths` ); } Object.assign( combination, getNestedFragments(fragments[node.name.value], fragments, errorLogger), { [node.name.value]: fragments[node.name.value] } ); } }); return combination; }