UNPKG

@atomist/automation-client

Version:

Atomist API for software low-level client

390 lines (345 loc) • 13.4 kB
import * as appRoot from "app-root-path"; import * as findUp from "find-up"; import * as fs from "fs"; import { GraphQLError, parse, } from "graphql"; import gql, { disableFragmentWarnings } from "graphql-tag"; import * as p from "path"; import { logger } from "../../util/logger"; import { findLine, generateHash, } from "../util/string"; disableFragmentWarnings(); // tslint:disable-next-line:no-var-requires const schema = require("../../graph/schema.json"); const OperationParameterExpression = /(?:subscription|query)[\s]*([\S]*?)\s*(\([\S\s]*?\))\s*[\S\s]*?{/i; const OperationNameExpression = /(subscription|query)[\s]*([^({\s]*)/i; const FragmentExpression = /\.\.\.\s*([_A-Za-z][_0-9A-Za-z]*)/gi; export class ParameterEnum { constructor(public value: string | string[]) { } } export function enumValue(value: string | string[]): ParameterEnum { return new ParameterEnum(value); } /** * see src/graph/graphQL.ts */ export function subscription(options: SubscriptionOptions): string { let s = options.subscription; const fragmentDir = options.fragmentDir; const path = options.path; const name = options.name; const pathToCallingFunction = options.moduleDir; // If subscription isn't defined attempt to load from file if (!s) { s = locateAndLoadGraphql({ path, name }, "subscription", pathToCallingFunction); } // Replace variables s = replaceParameters(s, options.variables); // Inline fragments s = inlineFragments(s, name, pathToCallingFunction, fragmentDir); if (options.operationName) { s = replaceOperationName(s, options.operationName); } // Inline entire subscription if (options.inline !== false) { s = inlineQuery(s); } return s; } export interface SubscriptionOptions { subscription?: string; path?: string; name?: string; fragmentDir?: string; inline?: boolean; variables?: { [name: string]: string | boolean | number | ParameterEnum; }; operationName?: string; moduleDir?: string; } /** * Prepare a GraphQL query string for the use with Apollo. * * Queries can be provided by the following options: * * * query: string containing the subscription GraphQL, or * * path: absolute or relative path to a .graphql file to load; if provided a relative * path this will resolve the relative path to an absolute given the location * of the calling script. * * name: name of the .graphql file to load; this will walk up the directory structure * starting at the location of the calling script and look for a folder called * 'graphql'. Once that folder is found, by convention name is being looked for * in the 'query' sub directory. * * fragmentsDir: location of fragment .graphql files * * moduleDir: location of the calling script * * inline: remove any unneeded whitespace and line breaks from returned GraphQL string * * @param {{query?: string; path?: string; name?: string; fragmentDir?: string; moduleDir: string; inline?: boolean}} options * @returns {string} */ export function query<T, Q>(options: QueryOptions): string { let q = options.query; const fragmentDir = options.fragmentDir; const path = options.path; const name = options.name; // If query isn't defined attempt to load from file if (!q) { q = locateAndLoadGraphql({ path, name }, "query", options.moduleDir); } // Inline fragments q = inlineFragments(q, name, options.moduleDir, fragmentDir); // Inline entire query if (options.inline === true) { q = inlineQuery(q); } return q; } export interface QueryOptions { query?: string; path?: string; name?: string; fragmentDir?: string; moduleDir: string; inline?: boolean; } /** * Prepare a GraphQL mutation string for the use with Apollo. * * Mutations can be provided by the following options: * * * mutation: string containing the subscription GraphQL, or * * path: absolute or relative path to a .graphql file to load; if provided a relative * path this will resolve the relative path to an absolute given the location * of the calling script. * * name: name of the .graphql file to load; this will walk up the directory structure * starting a t the location of the calling script and look for a folder called * 'graphql'. Once that folder is found, by convention name is being looked for * in the 'mutation' sub directory. * * moduleDir: location of the calling script * * inline: remove any unneeded whitespace and line breaks from returned GraphQL string * * @param {{mutation?: string; path?: string; name?: string; moduleDir: string; inline?: boolean}} options * @returns {string} */ export function mutate<T, Q>(options: MutationOptions): string { let m = options.mutation; const path = options.path; const name = options.name; // If mutation isn't defined attempt to load from file if (!m) { m = locateAndLoadGraphql({ path, name }, "mutation", options.moduleDir); } // Inline entire mutation if (options.inline === true) { m = inlineQuery(m); } return m; } export interface MutationOptions { mutation?: string; path?: string; name?: string; moduleDir: string; inline?: boolean; } /** * see src/graph/graphQL.ts */ export function ingester(options: IngesterOptions): string { const path = options.path; const name = options.name; const pathToCallingFunction = options.moduleDir; return locateAndLoadGraphql({ path, name }, "ingester", pathToCallingFunction); } export interface IngesterOptions { name?: string; path?: string; moduleDir?: string; } /** * Extract operationName from the provided query or subscription * @param {string} q * @returns {string} */ export function operationName(q: string): string { const graphql = parse(q); // TODO add some validation here return (graphql.definitions[0] as any).name.value; } /** * Inline the given query. Mainly useful for nicer log messages * @param {string} query * @returns {string} */ export function inlineQuery(q: string): string { return q.replace(/[\n\r]/g, "").replace(/\s\s+/g, " "); } /** * Replace the operation name in the query or subscription */ export function replaceOperationName(q: string, name: string): string { return q.replace(OperationNameExpression, `$1 ${name}`); } export function prettyPrintErrors(errors: GraphQLError[], q?: string): string { return errors.map(e => { let msg = `${e.message} ${e.locations.map(l => `[${l.line},${l.column}]`).join(", ")}`; if (q) { for (let i = 0; i < e.positions.length; i++) { msg += `\n${findLine(q, e.positions[i])}`; msg += `\n${Array(e.locations[i].column).join("-")}^`; } } return msg; }).join("\n\n"); } export function replaceParameters(q: string, parameters: { [name: string]: string | boolean | number | ParameterEnum; } = {}): string { if (Object.keys(parameters).length > 0) { const exp = OperationParameterExpression; if (exp.test(q)) { const result = exp.exec(q); // First delete the parameter declaration at the top of the subscription q = q.replace(result[2], ""); for (const key in parameters) { if (parameters.hasOwnProperty(key)) { const value = parameters[key] as any; if (!value) { throw new Error(`The value of variable '${key}' is undefined`); } // If value is defined it is a enum value if (value.value) { if (Array.isArray(value.value)) { q = replace(q, `\\$${key}`, `[${value.value.join(", ")}]`); } else { q = replace(q, `\\$${key}`, value.value); } } else { q = replace(q, `\\$${key}`, JSON.stringify(value)); } } } // Calulate hash to suffix the subscriptionName const hash = generateHash(q); q = replaceOperationName(q, `${result[1]}_${hash}`); } } return q; } function replace(q: string, key: string, value: string): string { return q.replace(new RegExp(`${key}\\b`, "g"), value); } function inlineFragments(q: string, name: string, moduleDir: string, fragmentDir: string): string { if (!fragmentDir && !name) { fragmentDir = p.dirname(moduleDir); } else if (!fragmentDir && name) { fragmentDir = p.resolve(findUp.sync("graphql", { cwd: p.resolve(p.dirname(moduleDir)), type: "directory", }), "fragment"); } else if (!p.isAbsolute(fragmentDir)) { fragmentDir = p.resolve(p.dirname(moduleDir), fragmentDir); } if (FragmentExpression.test(q)) { // Load all fragments const fragments = fs.readdirSync(fragmentDir).filter(f => f.endsWith(".graphql")).map(f => { const content = fs.readFileSync(p.join(fragmentDir, f)).toString(); const graphql = gql(content); return { name: (graphql.definitions[0]).name.value, kind: (graphql.definitions[0]).kind, body: content.slice(content.indexOf("{") + 1, content.lastIndexOf("}") - 1), }; }).filter(f => f.kind === "FragmentDefinition"); FragmentExpression.lastIndex = 0; let result; // tslint:disable-next-line:no-conditional-assignment while (result = FragmentExpression.exec(q)) { const fragment = fragments.find(f => f.name === result[1]); if (fragment) { q = replace(q, result[0], fragment.body); } else { throw new Error(`Fragment '${result[1]}' can't be found in '${fragmentDir}'`); } } } return q; } function locateAndLoadGraphql( options: { path?: string, name?: string, }, subfolder: string, moduleDir: string, ): string { let path = options.path; const name = options.name; // Read subscription from file if given if (options.path) { if (!path.endsWith(".graphql")) { path = `${path}.graphql`; } if (!p.isAbsolute(path)) { path = p.resolve(p.dirname(moduleDir), path); } } else if (options.name) { const cwd = p.resolve(p.dirname(moduleDir)); const graphqlDir = findUp.sync("graphql", { cwd, type: "directory" }); if (graphqlDir) { const queryDir = p.join(graphqlDir, subfolder); const queries = fs.readdirSync(queryDir).filter(f => f.endsWith(".graphql")).filter(f => { const content = fs.readFileSync(p.join(queryDir, f)).toString(); const graphql = gql(content); return (graphql.definitions[0]).name.value === options.name; }); if (queries.length === 1) { path = p.join(queryDir, queries[0]); } else if (queries.length === 0) { // Remove for next major release logger.warn(`No ${subfolder} graphql operation found for name '${options.name}'. ` + `Falling back to file name lookup. Support for file name lookup will be removed in a future release.`); if (!options.name.endsWith(".graphql")) { path = p.join(queryDir, `${options.name}.graphql`); } else { path = p.join(queryDir, options.name); } // End of remove for next major release // throw new Error(`No matching ${subfolder} graphql operation found for name '${options.name}'`); } else { throw new Error(`More then 1 matching ${subfolder} graphql operation found for name '${options.name}'`); } } else { throw new Error(`No graphql folder found anywhere above directory '${cwd}'. Consider specifying a path`); } } else { throw new Error("No name or path specified"); } if (fs.existsSync(path)) { return fs.readFileSync(path).toString(); } else { throw new Error(`GraphQL file '${path}' does not exist`); } } export function resolveAndReadFileSync(path: string, current: string = appRoot.path, parameters: { [name: string]: string | boolean | number | ParameterEnum; } = {}): string { if (!path.endsWith(".graphql")) { path = `${path}.graphql`; } const absolutePath = p.resolve(current, path); if (fs.existsSync(absolutePath)) { return replaceParameters(fs.readFileSync(absolutePath).toString(), parameters); } else { throw new Error(`GraphQL file '${absolutePath}' does not exist`); } }