@atomist/automation-client
Version:
Atomist API for software low-level client
390 lines (345 loc) • 13.4 kB
text/typescript
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`);
}
}