apollo
Version:
Command line tool for Apollo GraphQL
409 lines (407 loc) ⢠23.5 kB
JavaScript
;
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.formatHumanReadable = exports.formatCompositionErrorsMarkdown = exports.formatMarkdown = exports.formatTimePeriod = void 0;
const command_1 = require("@oclif/command");
const table_1 = require("table");
const graphql_1 = require("graphql");
const chalk_1 = __importDefault(require("chalk"));
const env_ci_1 = __importDefault(require("env-ci"));
const git_1 = require("../../git");
const Command_1 = require("../../Command");
const utils_1 = require("../../utils");
const graphqlTypes_1 = require("apollo-language-server/lib/graphqlTypes");
const moment_1 = __importDefault(require("moment"));
const lodash_sortby_1 = __importDefault(require("lodash.sortby"));
const sharedMessages_1 = require("../../utils/sharedMessages");
const formatChange = (change) => {
let color = (x) => x;
if (change.severity === graphqlTypes_1.ChangeSeverity.FAILURE) {
color = chalk_1.default.red;
}
const changeDictionary = {
[graphqlTypes_1.ChangeSeverity.FAILURE]: "FAIL",
[graphqlTypes_1.ChangeSeverity.NOTICE]: "PASS",
};
return {
severity: color(changeDictionary[change.severity]),
code: color(change.code),
description: color(change.description),
};
};
function formatTimePeriod(hours) {
if (hours <= 24) {
return (0, utils_1.pluralize)(hours, "hour");
}
return (0, utils_1.pluralize)(Math.floor(hours / 24), "day");
}
exports.formatTimePeriod = formatTimePeriod;
function formatMarkdown({ checkSchemaResult, graphName, serviceName, tag, graphCompositionID, }) {
const { diffToPrevious } = checkSchemaResult;
if (!diffToPrevious) {
throw new Error("checkSchemaResult.diffToPrevious missing");
}
const { validationConfig } = diffToPrevious;
let validationText = "";
if (validationConfig) {
const hours = Math.abs((0, moment_1.default)()
.add(validationConfig.from, "second")
.diff((0, moment_1.default)().add(validationConfig.to, "second"), "hours"));
validationText = `š¢ Compared **${(0, utils_1.pluralize)(diffToPrevious.changes.length, "schema change")}** against **${(0, utils_1.pluralize)(diffToPrevious.numberOfCheckedOperations, "operation")}** seen over the **last ${formatTimePeriod(hours)}**.`;
}
const breakingChanges = diffToPrevious.changes.filter((change) => change.severity === "FAILURE");
const affectedQueryCount = diffToPrevious.affectedQueries
? diffToPrevious.affectedQueries.length
: 0;
return `
### Apollo Service Check
š Validated your local schema against metrics from variant \`${tag}\` ${serviceName ? `for graph \`${serviceName}\` ` : ""}on graph \`${graphName}@${tag}\`.
${validationText}
${breakingChanges.length > 0
? `ā Found **${(0, utils_1.pluralize)(diffToPrevious.changes.filter((change) => change.severity === "FAILURE")
.length, "breaking change")}** that would affect **${(0, utils_1.pluralize)(affectedQueryCount, "operation")}** across **${(0, utils_1.pluralize)(diffToPrevious.affectedClients && diffToPrevious.affectedClients.length, "client")}**`
: diffToPrevious.changes.length === 0
? `ā
Found **no changes**.`
: `ā
Found **no breaking changes**.`}
š [View your service check details](${checkSchemaResult.targetUrl +
(graphCompositionID ? `?graphCompositionId=${graphCompositionID})` : `)`)}.
`;
}
exports.formatMarkdown = formatMarkdown;
function formatCompositionErrorsMarkdown({ compositionErrors, graphName, serviceName, tag, }) {
return `
### Apollo Service Check
š Validated graph composition for service \`${serviceName}\` on graph \`${graphName}@${tag}\`.
ā Found **${compositionErrors.length} composition errors**
| Service | Field | Message |
| --------- | --------- | --------- |
${compositionErrors
.map(({ service, field, message }) => `| ${service} | ${field} | ${message} |`)
.join("\n")}
`;
}
exports.formatCompositionErrorsMarkdown = formatCompositionErrorsMarkdown;
function formatHumanReadable({ checkSchemaResult, graphCompositionID, }) {
const { targetUrl, diffToPrevious: { changes }, } = checkSchemaResult;
let result = "";
if (changes.length === 0) {
result = "\nNo changes present between schemas";
}
else {
const sortedChanges = (0, lodash_sortby_1.default)(changes, [
(change) => change.code,
(change) => change.description,
]);
const breakingChanges = sortedChanges.filter((change) => change.severity === graphqlTypes_1.ChangeSeverity.FAILURE);
(0, lodash_sortby_1.default)(breakingChanges, (change) => change.severity);
const nonBreakingChanges = sortedChanges.filter((change) => change.severity !== graphqlTypes_1.ChangeSeverity.FAILURE);
result += (0, table_1.table)([
["Change", "Code", "Description"],
...[
...breakingChanges.map(formatChange).map(Object.values),
...nonBreakingChanges.map(formatChange).map(Object.values),
].filter(Boolean),
]);
}
if (targetUrl) {
result += `\n\nView full details at: ${targetUrl}${graphCompositionID ? `?graphCompositionId=${graphCompositionID}` : ``}`;
}
return result;
}
exports.formatHumanReadable = formatHumanReadable;
class ServiceCheck extends Command_1.ProjectCommand {
async run() {
this.printDeprecationWarning();
const taskOutput = {};
const breakingChangesErrorMessage = "breaking changes found";
const federatedServiceCompositionUnsuccessfulErrorMessage = "Federated service composition was unsuccessful. Please see the reasons below.";
const { isCi } = (0, env_ci_1.default)();
let schema;
let graphID;
let graphVariant;
try {
await this.runTasks(({ config, flags, project }) => {
graphID = config.graph;
graphVariant = config.variant;
const serviceName = flags.serviceName;
if (!graphID) {
throw sharedMessages_1.graphUndefinedError;
}
const graphSpecifier = `${graphID}@${graphVariant}`;
taskOutput.shouldOutputJson = !!flags.json;
taskOutput.shouldOutputMarkdown = !!flags.markdown;
taskOutput.shouldAlwaysExit0 = !!flags.ignoreFailures;
taskOutput.serviceName = flags.serviceName;
taskOutput.config = config;
return [
{
enabled: () => !!serviceName,
title: `Validate graph composition for service ${chalk_1.default.cyan(serviceName || "")} on graph ${chalk_1.default.cyan(graphSpecifier)}`,
task: async (ctx, task) => {
if (!serviceName) {
throw new Error("This task should not be run without a `serviceName`. Check the `enabled` function.");
}
task.output = "Fetching local service's partial schema";
const sdl = await project.resolveFederatedServiceSDL();
if (!sdl) {
throw new Error("No SDL found for federated service");
}
task.output = `Attempting to compose graph with ${chalk_1.default.cyan(serviceName)} service's partial schema`;
const historicParameters = (0, utils_1.validateHistoricParams)({
validationPeriod: flags.validationPeriod,
queryCountThreshold: flags.queryCountThreshold,
queryCountThresholdPercentage: flags.queryCountThresholdPercentage,
});
const gitInfoFromEnv = await (0, git_1.gitInfo)(this.log);
const { compositionValidationResult, checkSchemaResult } = await project.engine.checkPartialSchema(Object.assign(Object.assign({ id: graphID, graphVariant: graphVariant, implementingServiceName: serviceName, partialSchema: {
sdl,
} }, (historicParameters && { historicParameters })), { gitContext: Object.assign(Object.assign(Object.assign(Object.assign({}, gitInfoFromEnv), (flags.author
? { committer: flags.author }
: undefined)), (flags.branch ? { branch: flags.branch } : undefined)), (flags.commitId
? { commit: flags.commitId }
: undefined)) }));
task.title = `Found ${(0, utils_1.pluralize)(compositionValidationResult.errors.length, "graph composition error")} for service ${chalk_1.default.cyan(serviceName)} on graph ${chalk_1.default.cyan(graphSpecifier)}`;
if (compositionValidationResult.errors.length > 0) {
taskOutput.compositionErrors =
compositionValidationResult.errors
.filter(isNotNullOrUndefined)
.map((error) => {
const match = error.message.match(/^\[([^\[]+)\]\s+(\S+)\ ->\ (.+)/);
if (!match) {
return { message: error.message };
}
const [, service, field, message] = match;
return { service, field, message };
});
taskOutput.graphCompositionID =
compositionValidationResult.graphCompositionID;
this.error(federatedServiceCompositionUnsuccessfulErrorMessage);
}
else {
if (!checkSchemaResult) {
throw new Error("Violated invariant. Schema should have been validated against operations if" +
"there were no composition errors");
}
taskOutput.checkSchemaResult = checkSchemaResult;
ctx.checkSchemaResult = checkSchemaResult;
}
},
},
{
title: `Validating ${serviceName ? "composed " : ""}schema against metrics from variant ${chalk_1.default.cyan(graphVariant)} on graph ${chalk_1.default.cyan(graphSpecifier)}`,
enabled: () => !serviceName,
task: async (ctx, task) => {
let schemaCheckSchemaVariables;
task.output = "Resolving schema";
schema = await project.resolveSchema({ tag: config.variant });
if (!schema) {
throw new Error("Failed to resolve schema");
}
schemaCheckSchemaVariables = {
schema: (0, graphql_1.introspectionFromSchema)(schema)
.__schema,
};
const historicParameters = (0, utils_1.validateHistoricParams)({
validationPeriod: flags.validationPeriod,
queryCountThreshold: flags.queryCountThreshold,
queryCountThresholdPercentage: flags.queryCountThresholdPercentage,
});
task.output = "Validating schema";
const gitInfoFromEnv = await (0, git_1.gitInfo)(this.log);
const variables = Object.assign(Object.assign({ id: graphID, tag: config.variant, gitContext: Object.assign(Object.assign(Object.assign({}, gitInfoFromEnv), (flags.committer
? { committer: flags.committer }
: undefined)), (flags.branch ? { branch: flags.branch } : undefined)) }, (historicParameters && { historicParameters })), schemaCheckSchemaVariables);
const { schema: _ } = variables, restVariables = __rest(variables, ["schema"]);
this.debug("Variables sent to Apollo:");
this.debug(restVariables);
if (schema) {
this.debug("SDL of introspection sent to Apollo:");
this.debug((0, graphql_1.printSchema)(schema));
}
else {
this.debug("Schema hash generated:");
this.debug(schemaCheckSchemaVariables);
}
const checkSchemaResult = await project.engine.checkSchema(variables);
ctx.checkSchemaResult = checkSchemaResult;
taskOutput.checkSchemaResult = checkSchemaResult;
task.title = task.title.replace("Validating", "Validated");
},
},
{
title: "Comparing schema changes",
task: async (ctx, task) => {
const schemaChanges = ctx.checkSchemaResult.diffToPrevious.changes;
const numberOfCheckedOperations = ctx.checkSchemaResult.diffToPrevious
.numberOfCheckedOperations || 0;
const validationConfig = ctx.checkSchemaResult.diffToPrevious.validationConfig;
const hours = validationConfig
? Math.abs((0, moment_1.default)()
.add(validationConfig.from, "second")
.diff((0, moment_1.default)().add(validationConfig.to, "second"), "hours"))
: null;
task.title = `Compared ${(0, utils_1.pluralize)(chalk_1.default.cyan(schemaChanges.length.toString()), "schema change")} against ${(0, utils_1.pluralize)(chalk_1.default.cyan(numberOfCheckedOperations.toString()), "operation")}${hours
? ` over the last ${chalk_1.default.cyan(formatTimePeriod(hours))}`
: ""}`;
},
},
{
title: "Reporting result",
task: async (ctx, task) => {
const breakingSchemaChangeCount = ctx.checkSchemaResult.diffToPrevious.changes.filter((change) => change.severity === graphqlTypes_1.ChangeSeverity.FAILURE).length;
const nonBreakingSchemaChangeCount = ctx.checkSchemaResult.diffToPrevious.changes.length -
breakingSchemaChangeCount;
task.title = `Found ${(0, utils_1.pluralize)(chalk_1.default.cyan(breakingSchemaChangeCount.toString()), "breaking change")} and ${(0, utils_1.pluralize)(chalk_1.default.cyan(nonBreakingSchemaChangeCount.toString()), "compatible change")}`;
if (breakingSchemaChangeCount) {
throw new Error(breakingChangesErrorMessage);
}
},
},
];
}, (context) => ({
renderer: isCi
? utils_1.CompactRenderer
: context.flags.markdown || context.flags.json
? "silent"
: "default",
}));
}
catch (error) {
if (error.message.includes("/upgrade")) {
this.exit(1);
return;
}
if (error.message !== breakingChangesErrorMessage &&
error.message !== federatedServiceCompositionUnsuccessfulErrorMessage) {
throw error;
}
}
const { checkSchemaResult, config, shouldOutputJson, shouldOutputMarkdown, serviceName, compositionErrors, graphCompositionID, shouldAlwaysExit0, } = taskOutput;
if (shouldOutputJson) {
if (compositionErrors) {
return this.log(JSON.stringify({ errors: compositionErrors }, null, 2));
}
return this.log(JSON.stringify({
targetUrl: checkSchemaResult.targetUrl +
(graphCompositionID
? `?graphCompositionId=${graphCompositionID}`
: ``),
changes: checkSchemaResult.diffToPrevious.changes,
validationConfig: checkSchemaResult.diffToPrevious.validationConfig,
}, null, 2));
}
else if (shouldOutputMarkdown) {
if (!graphID) {
throw new Error("The graph name should have been defined in the Apollo config and validated when the config was loaded. Please file an issue if you're seeing this error.");
}
if (compositionErrors) {
if (!serviceName) {
throw new Error("Composition errors should only occur when `serviceName` is present. Please file an issue if you're seeing this error.");
}
return this.log(formatCompositionErrorsMarkdown({
compositionErrors,
graphName: graphID,
serviceName,
tag: config.variant,
}));
}
return this.log(formatMarkdown({
checkSchemaResult,
graphName: graphID,
serviceName,
tag: config.variant,
graphCompositionID,
}));
}
if (compositionErrors) {
console.log("");
const unformattedErrors = compositionErrors.filter((e) => !e.field && !e.service);
const formattedErrors = compositionErrors.filter((e) => e.field || e.service);
if (formattedErrors.length)
this.log((0, table_1.table)([
["Service", "Field", "Message"],
...formattedErrors.map(Object.values),
], {
columns: {
2: {
width: 50,
wrapWord: true,
},
},
}));
if (unformattedErrors.length)
this.log((0, table_1.table)([["Message"], ...unformattedErrors.map((e) => [e.message])]));
if (shouldAlwaysExit0) {
return;
}
this.exit(1);
}
else {
this.log(formatHumanReadable({ checkSchemaResult, graphCompositionID }));
if (checkSchemaResult.diffToPrevious.changes.find(({ severity }) => severity === graphqlTypes_1.ChangeSeverity.FAILURE)) {
if (shouldAlwaysExit0) {
return;
}
this.exit(1);
}
}
}
}
exports.default = ServiceCheck;
ServiceCheck.aliases = ["schema:check"];
ServiceCheck.description = "[DEPRECATED] Check a service against known operation workloads to find breaking changes" +
Command_1.ProjectCommand.DEPRECATION_MSG;
ServiceCheck.flags = Object.assign(Object.assign({}, Command_1.ProjectCommand.flags), { tag: command_1.flags.string({
char: "t",
description: "[Deprecated: please use --variant instead] The tag (AKA variant) to check the proposed schema against",
hidden: true,
exclusive: ["variant"],
}), variant: command_1.flags.string({
char: "v",
description: "The variant to check the proposed schema against",
exclusive: ["tag"],
}), graph: command_1.flags.string({
char: "g",
description: "The ID of the graph in Apollo to check your proposed schema changes against. Overrides config file if set.",
}), branch: command_1.flags.string({
description: "The branch name to associate with this check",
}), commitId: command_1.flags.string({
description: "The SHA-1 hash of the commit to associate with this check",
}), author: command_1.flags.string({
description: "The author to associate with this proposed schema",
}), validationPeriod: command_1.flags.string({
description: "The size of the time window with which to validate the schema against. You may provide a number (in seconds), or an ISO8601 format duration for more granularity (see: https://en.wikipedia.org/wiki/ISO_8601#Durations)",
}), queryCountThreshold: command_1.flags.integer({
description: "Minimum number of requests within the requested time window for a query to be considered.",
}), queryCountThresholdPercentage: command_1.flags.integer({
description: "Number of requests within the requested time window for a query to be considered, relative to total request count. Expected values are between 0 and 0.05 (minimum 5% of total request volume)",
}), json: command_1.flags.boolean({
description: "Output result in json, which can then be parsed by CLI tools such as jq.",
exclusive: ["markdown"],
}), localSchemaFile: command_1.flags.string({
description: "Path to one or more local GraphQL schema file(s), as introspection result or SDL. Supports comma-separated list of paths (ex. `--localSchemaFile=schema.graphql,extensions.graphql`)",
}), markdown: command_1.flags.boolean({
description: "Output result in markdown.",
exclusive: ["json"],
}), serviceName: command_1.flags.string({
description: "Provides the name of the implementing service for a federated graph. This flag will indicate that the schema is a partial schema from a federated service",
}), ignoreFailures: command_1.flags.boolean({
description: "Exit with status 0 when the check completes, even if errors are found",
}) });
function isNotNullOrUndefined(value) {
return value !== null && typeof value !== "undefined";
}
//# sourceMappingURL=check.js.map