Skip to content

Commit

Permalink
Add option to auto-confirm all prompts (#106)
Browse files Browse the repository at this point in the history
* Add option to always confirm downloads

The -y option can be provided to automatically respond "yes" to
any user prompt that asks if you want to download a specific file.
These prompts appear when the size of the file exceeds a threshold
and when the size of the file is unknown.

Download functions moved to DownloadManager class. This class stores
the value of the "yes to all downloads" option, which allows it to
skip showing prompts when set.

* Apply yes-always flag to referenced files

* Clean up usage of output path argument

* further cleanup of outputPath

* update from npm audit

* Revert "update from npm audit"

This reverts commit c7f5aa8.

* remove redundant output copying
  • Loading branch information
mint-thompson authored Oct 20, 2023
1 parent ac7a636 commit f53ed63
Show file tree
Hide file tree
Showing 10 changed files with 404 additions and 334 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Options:
-t, --target <schema> name of schema to use (choices: "allowed-amounts", "in-network-rates", "provider-reference", "table-of-contents",
default: "in-network-rates")
-s, --strict enable strict checking, which prohibits additional properties in data file
-y, --yes-all automatically respond "yes" to confirmation prompts
-h, --help display help for command
```

Expand Down Expand Up @@ -151,6 +152,7 @@ Options:
-t, --target <schema> name of schema to use (choices: "allowed-amounts", "in-network-rates", "provider-reference", "table-of-contents",
default: "in-network-rates")
-s, --strict enable strict checking, which prohibits additional properties in data file
-y, --yes-all automatically respond "yes" to confirmation prompts
-h, --help display help for command
```

Expand Down
15 changes: 5 additions & 10 deletions src/DockerManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { logger } from './logger';
export class DockerManager {
containerId = '';

constructor() {}
constructor(public outputPath = '') {}

private async initContainerId(): Promise<void> {
this.containerId = await util
Expand All @@ -24,7 +24,7 @@ export class DockerManager {
schemaPath: string,
schemaName: string,
dataPath: string,
outputPath: string
outputPath = this.outputPath
): Promise<ContainerResult> {
try {
if (this.containerId.length === 0) {
Expand All @@ -42,11 +42,8 @@ export class DockerManager {
logger.debug(runCommand);
return util
.promisify(exec)(runCommand)
.then(result => {
.then(() => {
const containerResult: ContainerResult = { pass: true };
if (outputPath && fs.existsSync(containerOutputPath)) {
fs.copySync(containerOutputPath, outputPath);
}
if (fs.existsSync(containerOutputPath)) {
if (outputPath) {
fs.copySync(containerOutputPath, outputPath);
Expand All @@ -64,10 +61,7 @@ export class DockerManager {
}
return containerResult;
})
.catch(reason => {
if (outputPath && fs.existsSync(path.join(outputDir, 'output.txt'))) {
fs.copySync(path.join(outputDir, 'output.txt'), outputPath);
}
.catch(() => {
if (fs.existsSync(containerOutputPath)) {
if (outputPath) {
fs.copySync(containerOutputPath, outputPath);
Expand Down Expand Up @@ -115,6 +109,7 @@ export class DockerManager {

export type ContainerResult = {
pass: boolean;
text?: string;
locations?: {
inNetwork?: string[];
allowedAmount?: string[];
Expand Down
162 changes: 162 additions & 0 deletions src/DownloadManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import axios from 'axios';
import readlineSync from 'readline-sync';
import fs from 'fs-extra';
import temp from 'temp';
import path from 'path';
import yauzl from 'yauzl';
import { createGunzip } from 'zlib';
import { ZipContents, isGzip, isZip } from './utils';
import { logger } from './logger';

import { pipeline } from 'stream/promises';

const ONE_MEGABYTE = 1024 * 1024;
const DATA_SIZE_WARNING_THRESHOLD = ONE_MEGABYTE * 1024; // 1 gigabyte

export class DownloadManager {
folder: string;

constructor(public alwaysYes = false) {
temp.track();
this.folder = temp.mkdirSync();
}

async checkDataUrl(url: string): Promise<boolean> {
try {
const response = await axios.head(url);
if (response.status === 200) {
let proceedToDownload: boolean;
const contentLength = parseInt(response.headers['content-length']);
if (this.alwaysYes) {
proceedToDownload = true;
} else if (isNaN(contentLength)) {
proceedToDownload = readlineSync.keyInYNStrict(
'Data file size is unknown. Download this file?'
);
} else if (contentLength > DATA_SIZE_WARNING_THRESHOLD) {
proceedToDownload = readlineSync.keyInYNStrict(
`Data file is ${(contentLength / ONE_MEGABYTE).toFixed(
2
)} MB in size. Download this file?`
);
} else {
proceedToDownload = true;
}
return proceedToDownload;
} else {
logger.error(
`Received unsuccessful status code ${response.status} when checking data file URL: ${url}`
);
return false;
}
} catch (e) {
logger.error(`Request failed when checking data file URL: ${url}`);
logger.error(e.message);
return false;
}
}

async downloadDataFile(url: string, folder = this.folder): Promise<string | ZipContents> {
const filenameGuess = 'data.json';
const dataPath = path.join(folder, filenameGuess);
return new Promise((resolve, reject) => {
logger.info('Beginning download...\n');
axios({
method: 'get',
url: url,
responseType: 'stream',
onDownloadProgress: progressEvent => {
if (process.stdout.isTTY) {
let progressText: string;
if (progressEvent.progress != null) {
progressText = `Downloaded ${Math.floor(progressEvent.progress * 100)}% of file (${
progressEvent.loaded
} bytes)`;
} else {
progressText = `Downloaded ${progressEvent.loaded} bytes`;
}
process.stdout.clearLine(0, () => {
process.stdout.cursorTo(0, () => {
process.stdout.moveCursor(0, -1, () => {
logger.info(progressText);
});
});
});
}
}
})
.then(response => {
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
const finalUrl = response.request.path;
if (isZip(contentType, finalUrl)) {
// zips require additional work to find a JSON file inside
const zipPath = path.join(folder, 'data.zip');
const zipOutputStream = fs.createWriteStream(zipPath);
pipeline(response.data, zipOutputStream).then(() => {
yauzl.open(zipPath, { lazyEntries: true, autoClose: false }, (err, zipFile) => {
if (err != null) {
reject(err);
}
const jsonEntries: yauzl.Entry[] = [];

zipFile.on('entry', (entry: yauzl.Entry) => {
if (entry.fileName.endsWith('.json')) {
jsonEntries.push(entry);
}
zipFile.readEntry();
});

zipFile.on('end', () => {
logger.info('Download complete.');
if (jsonEntries.length === 0) {
reject('No JSON file present in zip.');
} else {
let chosenEntry: yauzl.Entry;
if (jsonEntries.length === 1) {
chosenEntry = jsonEntries[0];
zipFile.openReadStream(chosenEntry, (err, readStream) => {
const outputStream = fs.createWriteStream(dataPath);
outputStream.on('finish', () => {
zipFile.close();
resolve(dataPath);
});
outputStream.on('error', () => {
zipFile.close();
reject('Error writing downloaded file.');
});
readStream.pipe(outputStream);
});
} else {
jsonEntries.sort((a, b) => {
return a.fileName.localeCompare(b.fileName);
});
resolve({ zipFile, jsonEntries, dataPath });
}
}
});
zipFile.readEntry();
});
});
} else {
const outputStream = fs.createWriteStream(dataPath);
outputStream.on('finish', () => {
logger.info('Download complete.');
resolve(dataPath);
});
outputStream.on('error', () => {
reject('Error writing downloaded file.');
});

if (isGzip(contentType, finalUrl)) {
pipeline(response.data, createGunzip(), outputStream);
} else {
response.data.pipe(outputStream);
}
}
})
.catch(reason => {
reject('Error downloading data file.');
});
});
}
}
38 changes: 16 additions & 22 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { OptionValues } from 'commander';

import {
config,
downloadDataFile,
checkDataUrl,
chooseJsonFile,
getEntryFromZip,
assessTocContents,
Expand All @@ -18,6 +16,7 @@ import temp from 'temp';
import { SchemaManager } from './SchemaManager';
import { DockerManager } from './DockerManager';
import { logger } from './logger';
import { DownloadManager } from './DownloadManager';

export async function validate(dataFile: string, options: OptionValues) {
// check to see if supplied json file exists
Expand Down Expand Up @@ -61,26 +60,26 @@ export async function validate(dataFile: string, options: OptionValues) {
.then(async schemaPath => {
temp.track();
if (schemaPath != null) {
const dockerManager = new DockerManager();
const dockerManager = new DockerManager(options.out);
const downloadManager = new DownloadManager(options.yesAll);
const containerResult = await dockerManager.runContainer(
schemaPath,
options.target,
dataFile,
options.out
dataFile
);
if (containerResult.pass) {
if (options.target === 'table-of-contents') {
const providerReferences = await assessTocContents(
containerResult.locations,
schemaManager,
dockerManager,
options.out
downloadManager
);
await assessReferencedProviders(
providerReferences,
schemaManager,
dockerManager,
options.out
downloadManager
);
} else if (
options.target === 'in-network-rates' &&
Expand All @@ -90,7 +89,7 @@ export async function validate(dataFile: string, options: OptionValues) {
containerResult.locations.providerReference,
schemaManager,
dockerManager,
options.out
downloadManager
);
}
}
Expand All @@ -108,7 +107,8 @@ export async function validate(dataFile: string, options: OptionValues) {

export async function validateFromUrl(dataUrl: string, options: OptionValues) {
temp.track();
if (await checkDataUrl(dataUrl)) {
const downloadManager = new DownloadManager(options.yesAll);
if (await downloadManager.checkDataUrl(dataUrl)) {
const schemaManager = new SchemaManager();
await schemaManager.ensureRepo();
schemaManager.strict = options.strict;
Expand All @@ -121,28 +121,27 @@ export async function validateFromUrl(dataUrl: string, options: OptionValues) {
})
.then(async schemaPath => {
if (schemaPath != null) {
const dockerManager = new DockerManager();
const dataFile = await downloadDataFile(dataUrl, temp.mkdirSync());
const dockerManager = new DockerManager(options.out);
const dataFile = await downloadManager.downloadDataFile(dataUrl);
if (typeof dataFile === 'string') {
const containerResult = await dockerManager.runContainer(
schemaPath,
options.target,
dataFile,
options.out
dataFile
);
if (containerResult.pass) {
if (options.target === 'table-of-contents') {
const providerReferences = await assessTocContents(
containerResult.locations,
schemaManager,
dockerManager,
options.out
downloadManager
);
await assessReferencedProviders(
providerReferences,
schemaManager,
dockerManager,
options.out
downloadManager
);
} else if (
options.target === 'in-network-rates' &&
Expand All @@ -152,7 +151,7 @@ export async function validateFromUrl(dataUrl: string, options: OptionValues) {
containerResult.locations.providerReference,
schemaManager,
dockerManager,
options.out
downloadManager
);
}
}
Expand All @@ -163,12 +162,7 @@ export async function validateFromUrl(dataUrl: string, options: OptionValues) {
while (continuation === true) {
const chosenEntry = chooseJsonFile(dataFile.jsonEntries);
await getEntryFromZip(dataFile.zipFile, chosenEntry, dataFile.dataPath);
await dockerManager.runContainer(
schemaPath,
options.target,
dataFile.dataPath,
options.out
);
await dockerManager.runContainer(schemaPath, options.target, dataFile.dataPath);
continuation = readlineSync.keyInYNStrict(
'Would you like to validate another file in the ZIP?'
);
Expand Down
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ async function main() {
.name('cms-mrf-validator')
.description('Tool for validating health coverage machine-readable files.')
.option('-d, --debug', 'show debug output')
.hook('preAction', (thisCommand, actionCommand) => {
.hook('preAction', thisCommand => {
if (thisCommand.opts().debug) {
logger.level = 'debug';
logger.debug(process.argv.join(' '));
Expand All @@ -35,6 +35,7 @@ async function main() {
'-s, --strict',
'enable strict checking, which prohibits additional properties in data file'
)
.option('-y, --yes-all', 'automatically respond "yes" to confirmation prompts')
.action(validate);

program
Expand All @@ -55,6 +56,7 @@ async function main() {
'-s, --strict',
'enable strict checking, which prohibits additional properties in data file'
)
.option('-y, --yes-all', 'automatically respond "yes" to confirmation prompts')
.action((dataUrl, options) => {
validateFromUrl(dataUrl, options).then(result => {
if (result) {
Expand Down
Loading

0 comments on commit f53ed63

Please sign in to comment.