Skip to content

Commit 4c0d437

Browse files
authored
feat(cli-repl): add --json CLI flag MONGOSH-1249 (#1342)
Add a `--json` CLI flag with possible `--json=canonical` and `--json=relaxed` flags, the default being `canonical`. When used, mongosh will print Extended JSON as output. This flag can only be used in conjunction with `--eval`, and is mutually exclusive with `--shell` and providing files on the command line. Errors and regular output are not distinguished based on the output alone, only in the exit code of mongosh. Repeated errors while trying to serialize the output to Extended JSON will not be serialized.
1 parent 8e365a5 commit 4c0d437

File tree

9 files changed

+216
-12
lines changed

9 files changed

+216
-12
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ variable. For detailed instructions for each of our supported platforms, please
3939
--nodb Don't connect to mongod on startup - no 'db address' [arg] expected
4040
--norc Will not run the '.mongoshrc.js' file on start up
4141
--eval [arg] Evaluate javascript
42-
--retryWrites=[true|false] Automatically retry write operations upon transient network errors (Default: true)
42+
--json[=canonical|relaxed] Print result of --eval as Extended JSON, including errors
43+
--retryWrites[=true|false] Automatically retry write operations upon transient network errors (Default: true)
4344
4445
Authentication Options:
4546

packages/arg-parser/src/cli-options.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface CliOptions {
2626
help?: boolean;
2727
host?: string;
2828
ipv6?: boolean;
29+
json?: boolean | 'canonical' | 'relaxed';
2930
keyVaultNamespace?: string;
3031
kmsURL?: string;
3132
nodb?: boolean;

packages/cli-repl/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ CLI interface for [MongoDB Shell][mongosh], an extension to Node.js REPL with Mo
2121
--nodb Don't connect to mongod on startup - no 'db address' [arg] expected
2222
--norc Will not run the '.mongoshrc.js' file on start up
2323
--eval [arg] Evaluate javascript
24-
--retryWrites=[true|false] Automatically retry write operations upon transient network errors (Default: true)
24+
--json[=canonical|relaxed] Print result of --eval as Extended JSON, including errors
25+
--retryWrites[=true|false] Automatically retry write operations upon transient network errors (Default: true)
2526
2627
Authentication Options:
2728

packages/cli-repl/src/arg-parser.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ const OPTIONS = {
8383
p: 'password',
8484
u: 'username',
8585
f: 'file',
86-
'build-info': 'buildInfo'
86+
'build-info': 'buildInfo',
87+
json: 'json' // List explicitly here since it can be a boolean or a string
8788
},
8889
configuration: {
8990
'camel-case-expansion': false,
@@ -194,6 +195,13 @@ export function verifyCliArguments(args: any /* CliOptions */): string[] {
194195
}
195196
}
196197

198+
if (![undefined, true, false, 'relaxed', 'canonical'].includes(args.json)) {
199+
throw new MongoshUnimplementedError(
200+
'--json can only have the values relaxed or canonical',
201+
CommonErrors.InvalidArgument
202+
);
203+
}
204+
197205
const messages = [];
198206
for (const deprecated in DEPRECATED_ARGS_WITH_REPLACEMENT) {
199207
if (deprecated in args) {

packages/cli-repl/src/cli-repl.spec.ts

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,137 @@ describe('CliRepl', () => {
523523
});
524524
});
525525

526+
context('in --json mode', () => {
527+
beforeEach(() => {
528+
cliReplOptions.shellCliOptions.quiet = true;
529+
});
530+
531+
it('serializes results as EJSON with --json', async() => {
532+
cliReplOptions.shellCliOptions.eval = ['({ a: Long("0") })'];
533+
cliReplOptions.shellCliOptions.json = true;
534+
cliRepl = new CliRepl(cliReplOptions);
535+
await startWithExpectedImmediateExit(cliRepl, '');
536+
expect(JSON.parse(output)).to.deep.equal({ a: { $numberLong: '0' } });
537+
expect(exitCode).to.equal(0);
538+
});
539+
540+
it('serializes results as EJSON with --json=canonical', async() => {
541+
cliReplOptions.shellCliOptions.eval = ['({ a: Long("0") })'];
542+
cliReplOptions.shellCliOptions.json = 'canonical';
543+
cliRepl = new CliRepl(cliReplOptions);
544+
await startWithExpectedImmediateExit(cliRepl, '');
545+
expect(JSON.parse(output)).to.deep.equal({ a: { $numberLong: '0' } });
546+
expect(exitCode).to.equal(0);
547+
});
548+
549+
it('serializes results as EJSON with --json=relaxed', async() => {
550+
cliReplOptions.shellCliOptions.eval = ['({ a: Long("0") })'];
551+
cliReplOptions.shellCliOptions.json = 'relaxed';
552+
cliRepl = new CliRepl(cliReplOptions);
553+
await startWithExpectedImmediateExit(cliRepl, '');
554+
expect(JSON.parse(output)).to.deep.equal({ a: 0 });
555+
expect(exitCode).to.equal(0);
556+
});
557+
558+
it('serializes user errors as EJSON with --json', async() => {
559+
cliReplOptions.shellCliOptions.eval = ['throw new Error("asdf")'];
560+
cliReplOptions.shellCliOptions.json = true;
561+
cliRepl = new CliRepl(cliReplOptions);
562+
await startWithExpectedImmediateExit(cliRepl, '');
563+
const parsed = JSON.parse(output);
564+
expect(parsed).to.haveOwnProperty('message', 'asdf');
565+
expect(parsed).to.haveOwnProperty('name', 'Error');
566+
expect(parsed.stack).to.be.a('string');
567+
expect(exitCode).to.equal(1);
568+
});
569+
570+
it('serializes mongosh errors as EJSON with --json', async() => {
571+
cliReplOptions.shellCliOptions.eval = ['db'];
572+
cliReplOptions.shellCliOptions.json = true;
573+
cliRepl = new CliRepl(cliReplOptions);
574+
await startWithExpectedImmediateExit(cliRepl, '');
575+
const parsed = JSON.parse(output);
576+
expect(parsed).to.haveOwnProperty('message', '[SHAPI-10004] No connected database');
577+
expect(parsed).to.haveOwnProperty('name', 'MongoshInvalidInputError');
578+
expect(parsed).to.haveOwnProperty('code', 'SHAPI-10004');
579+
expect(parsed.stack).to.be.a('string');
580+
expect(exitCode).to.equal(1);
581+
});
582+
583+
it('serializes primitive exceptions as EJSON with --json', async() => {
584+
cliReplOptions.shellCliOptions.eval = ['throw null'];
585+
cliReplOptions.shellCliOptions.json = true;
586+
cliRepl = new CliRepl(cliReplOptions);
587+
await startWithExpectedImmediateExit(cliRepl, '');
588+
const parsed = JSON.parse(output);
589+
expect(parsed).to.haveOwnProperty('message', 'null');
590+
expect(parsed).to.haveOwnProperty('name', 'Error');
591+
expect(parsed.stack).to.be.a('string');
592+
expect(exitCode).to.equal(1);
593+
});
594+
595+
it('handles first-attempt EJSON serialization errors', async() => {
596+
cliReplOptions.shellCliOptions.eval = ['({ toJSON() { throw new Error("nested error"); }})'];
597+
cliReplOptions.shellCliOptions.json = true;
598+
cliRepl = new CliRepl(cliReplOptions);
599+
await startWithExpectedImmediateExit(cliRepl, '');
600+
const parsed = JSON.parse(output);
601+
expect(parsed).to.haveOwnProperty('message', 'nested error');
602+
expect(parsed).to.haveOwnProperty('name', 'Error');
603+
expect(parsed.stack).to.be.a('string');
604+
expect(exitCode).to.equal(1);
605+
});
606+
607+
it('does not handle second-attempt EJSON serialization errors', async() => {
608+
cliReplOptions.shellCliOptions.eval = ['({ toJSON() { throw ({ toJSON() { throw new Error("nested error") }}) }})'];
609+
cliReplOptions.shellCliOptions.json = true;
610+
cliRepl = new CliRepl(cliReplOptions);
611+
try {
612+
await cliRepl.start('', {});
613+
expect.fail('missed exception');
614+
} catch (err) {
615+
expect(err.message).to.equal('nested error');
616+
}
617+
});
618+
619+
it('rejects --json without --eval specifications', async() => {
620+
cliReplOptions.shellCliOptions.json = true;
621+
cliRepl = new CliRepl(cliReplOptions);
622+
try {
623+
await cliRepl.start('', {});
624+
expect.fail('missed exception');
625+
} catch (err) {
626+
expect(err.message).to.equal('Cannot use --json without --eval or with --shell or with extra files');
627+
}
628+
});
629+
630+
it('rejects --json with --shell specifications', async() => {
631+
cliReplOptions.shellCliOptions.eval = ['1'];
632+
cliReplOptions.shellCliOptions.json = true;
633+
cliReplOptions.shellCliOptions.shell = true;
634+
cliRepl = new CliRepl(cliReplOptions);
635+
try {
636+
await cliRepl.start('', {});
637+
expect.fail('missed exception');
638+
} catch (err) {
639+
expect(err.message).to.equal('Cannot use --json without --eval or with --shell or with extra files');
640+
}
641+
});
642+
643+
it('rejects --json with --file specifications', async() => {
644+
cliReplOptions.shellCliOptions.eval = ['1'];
645+
cliReplOptions.shellCliOptions.json = true;
646+
cliReplOptions.shellCliOptions.fileNames = ['a.js'];
647+
cliRepl = new CliRepl(cliReplOptions);
648+
try {
649+
await cliRepl.start('', {});
650+
expect.fail('missed exception');
651+
} catch (err) {
652+
expect(err.message).to.equal('Cannot use --json without --eval or with --shell or with extra files');
653+
}
654+
});
655+
});
656+
526657
context('with a global configuration file', () => {
527658
it('loads a global config file as YAML if present', async() => {
528659
const globalConfigFile = path.join(tmpdir.path, 'globalconfig.conf');
@@ -1402,7 +1533,7 @@ describe('CliRepl', () => {
14021533

14031534
context('with a mongos', () => {
14041535
verifyAutocompletion({
1405-
testServer: startTestServer('not-shared', '--replicaset', '--sharded', '0'),
1536+
testServer: startTestServer('not-shared', '--replicaset', '--csrs', '--sharded', '0'),
14061537
wantWatch: true,
14071538
wantShardDistribution: true,
14081539
hasCollectionNames: false, // We're only spinning up a mongos here

packages/cli-repl/src/cli-repl.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { StyleDefinition } from './clr';
1919
import { ConfigManager, ShellHomeDirectory, ShellHomePaths } from './config-directory';
2020
import { CliReplErrors } from './error-codes';
2121
import type { CryptLibraryPathResult } from './crypt-library-paths';
22+
import { formatForJSONOutput } from './format-json';
2223
import { MongoLogManager, MongoLogWriter, mongoLogId } from 'mongodb-log-writer';
2324
import MongoshNodeRepl, { MongoshNodeReplOptions, MongoshIOProvider } from './mongosh-repl';
2425
import { setupLoggerAndTelemetry, ToggleableAnalytics } from '@mongosh/logging';
@@ -289,6 +290,10 @@ class CliRepl implements MongoshIOProvider {
289290
const willExecuteCommandLineScripts = commandLineLoadFiles.length > 0 || evalScripts.length > 0;
290291
const willEnterInteractiveMode = !willExecuteCommandLineScripts || !!this.cliOptions.shell;
291292

293+
if ((evalScripts.length === 0 || this.cliOptions.shell || commandLineLoadFiles.length > 0) && this.cliOptions.json) {
294+
throw new MongoshRuntimeError('Cannot use --json without --eval or with --shell or with extra files');
295+
}
296+
292297
let snippetManager: SnippetManager | undefined;
293298
if (this.config.snippetIndexSourceURLs !== '') {
294299
snippetManager = SnippetManager.create({
@@ -309,7 +314,11 @@ class CliRepl implements MongoshIOProvider {
309314
if (willExecuteCommandLineScripts) {
310315
this.mongoshRepl.setIsInteractive(willEnterInteractiveMode);
311316
this.bus.emit('mongosh:start-loading-cli-scripts', { usesShellOption: !!this.cliOptions.shell });
312-
await this.loadCommandLineFilesAndEval(commandLineLoadFiles, evalScripts);
317+
const exitCode = await this.loadCommandLineFilesAndEval(commandLineLoadFiles, evalScripts);
318+
if (exitCode !== 0) {
319+
await this.exit(exitCode);
320+
return;
321+
}
313322
if (!this.cliOptions.shell) {
314323
// We flush the telemetry data as part of exiting. Make sure we have
315324
// the right config value.
@@ -364,14 +373,47 @@ class CliRepl implements MongoshIOProvider {
364373
}
365374
}
366375

367-
async loadCommandLineFilesAndEval(files: string[], evalScripts: string[]) {
368-
let lastEvalResult;
369-
for (const script of evalScripts) {
370-
this.bus.emit('mongosh:eval-cli-script');
371-
lastEvalResult = await this.mongoshRepl.loadExternalCode(script, '@(shell eval)');
376+
async loadCommandLineFilesAndEval(files: string[], evalScripts: string[]): Promise<number> {
377+
let lastEvalResult: any;
378+
let exitCode = 0;
379+
try {
380+
for (const script of evalScripts) {
381+
this.bus.emit('mongosh:eval-cli-script');
382+
lastEvalResult = await this.mongoshRepl.loadExternalCode(script, '@(shell eval)');
383+
}
384+
} catch (err) {
385+
// We have two distinct flows of control in the exception case;
386+
// if we are running in --json mode, we treat the error as a
387+
// special kind of output, otherwise we just pass the exception along.
388+
// We should *probably* change this so that CliRepl.start() doesn't result
389+
// in any user-caused exceptions, including script execution or failure to
390+
// connect, and instead always take the --json flow, but that feels like
391+
// it might be too big of a breaking change right now.
392+
exitCode = 1;
393+
if (this.cliOptions.json) {
394+
lastEvalResult = err;
395+
} else {
396+
throw err;
397+
}
372398
}
373399
if (lastEvalResult !== undefined) {
374-
this.output.write(this.mongoshRepl.writer(lastEvalResult) + '\n');
400+
let formattedResult;
401+
if (this.cliOptions.json) {
402+
try {
403+
formattedResult = formatForJSONOutput(lastEvalResult, this.cliOptions.json);
404+
} catch (e) {
405+
// If formatting the result as JSON fails, instead treat the error
406+
// itself as the output, as if the script had been e.g.
407+
// `try { ... } catch(e) { throw EJSON.serialize(e); }`
408+
// Do not try to format as EJSON repeatedly, if it fails then
409+
// there's little we can do about it.
410+
exitCode = 1;
411+
formattedResult = formatForJSONOutput(e, this.cliOptions.json);
412+
}
413+
} else {
414+
formattedResult = this.mongoshRepl.writer(lastEvalResult);
415+
}
416+
this.output.write(formattedResult + '\n');
375417
}
376418

377419
for (const file of files) {
@@ -380,6 +422,7 @@ class CliRepl implements MongoshIOProvider {
380422
}
381423
await this.mongoshRepl.loadExternalFile(file);
382424
}
425+
return exitCode;
383426
}
384427

385428
/**

packages/cli-repl/src/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ export const USAGE = `
2929
--nodb ${i18n.__('cli-repl.args.nodb')}
3030
--norc ${i18n.__('cli-repl.args.norc')}
3131
--eval [arg] ${i18n.__('cli-repl.args.eval')}
32-
--retryWrites ${i18n.__('cli-repl.args.retryWrites')}
32+
--json[=canonical|relaxed] ${i18n.__('cli-repl.args.json')}
33+
--retryWrites[=true|false] ${i18n.__('cli-repl.args.retryWrites')}
3334
3435
${clr(i18n.__('cli-repl.args.authenticationOptions'), 'mongosh:section-header')}
3536
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { types } from 'util';
2+
import { bson } from '@mongosh/service-provider-core';
3+
4+
export function formatForJSONOutput(value: any, mode: 'relaxed' | 'canonical' | true): string {
5+
if (types.isNativeError(value)) {
6+
value = {
7+
...value,
8+
message: value.message,
9+
stack: value.stack,
10+
name: value.name
11+
};
12+
}
13+
14+
return bson.EJSON.stringify(value, undefined, 2, {
15+
relaxed: mode === 'relaxed'
16+
});
17+
}

packages/i18n/src/locales/en_US.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const translations: Catalog = {
2222
nodb: "Don't connect to mongod on startup - no 'db address' [arg] expected",
2323
norc: "Will not run the '.mongoshrc.js' file on start up",
2424
eval: 'Evaluate javascript',
25+
json: 'Print result of --eval as Extended JSON, including errors',
2526
retryWrites: 'Automatically retry write operations upon transient network errors (Default: true)',
2627
authenticationOptions: 'Authentication Options:',
2728
username: 'Username for authentication',

0 commit comments

Comments
 (0)