diff --git a/package-lock.json b/package-lock.json index 678ada06987..3be435f3850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2377,6 +2377,14 @@ "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.10.tgz", @@ -5632,6 +5640,24 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -7523,6 +7549,14 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "node_modules/@types/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-jPypHl3prv913YGBxUlANJbI875RE4NZTOkyhWXTB5k/hW18fnv3nKE2M4WqJ7gAxcr6h3lXrYndryLmrYlMFg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/styled-components": { "version": "5.1.26", "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.26.tgz", @@ -15200,6 +15234,36 @@ "node": ">=8" } }, + "node_modules/is-invalid-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-invalid-path/-/is-invalid-path-0.1.0.tgz", + "integrity": "sha512-aZMG0T3F34mTg4eTdszcGXx54oiZ4NtHSft3hWNJMGJXUUqdIj3cOZuHcU0nCWWcY3jd7yRe/3AEm3vSNTpBGQ==", + "dependencies": { + "is-glob": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-invalid-path/node_modules/is-extglob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", + "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-invalid-path/node_modules/is-glob": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", + "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", + "dependencies": { + "is-extglob": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-lambda": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", @@ -15396,6 +15460,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-valid-path": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-valid-path/-/is-valid-path-0.1.1.tgz", + "integrity": "sha512-+kwPrVDu9Ms03L90Qaml+79+6DZHqHyRoANI6IsZJ/g8frhnfchDOBCa0RbQ6/kdHt5CS5OeIEyrYznNuVN+8A==", + "dependencies": { + "is-invalid-path": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -17347,6 +17422,18 @@ "jiti": "bin/jiti.js" } }, + "node_modules/joi": { + "version": "17.10.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-17.10.2.tgz", + "integrity": "sha512-hcVhjBxRNW/is3nNLdGLIjkgXetkeGc2wyhydhz8KumG23Aerk4HPjU5zaPAMRqXQFc0xNqXTC7+zQjxr0GlKA==", + "dependencies": { + "@hapi/hoek": "^9.0.0", + "@hapi/topo": "^5.0.0", + "@sideway/address": "^4.1.3", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, "node_modules/jose": { "version": "4.14.4", "resolved": "https://registry.npmjs.org/jose/-/jose-4.14.4.tgz", @@ -24900,6 +24987,7 @@ "@stoplight/spectral-formats": "^1.5.0", "@stoplight/spectral-ruleset-bundler": "1.5.2", "@stoplight/spectral-rulesets": "^1.16.0", + "@types/strip-final-newline": "3.0.0", "apiconnect-wsdl": "1.8.31", "aws4": "^1.12.0", "axios": "^1.4.0", @@ -24920,7 +25008,9 @@ "html-entities": "^2.4.0", "httpsnippet": "^2.0.0", "iconv-lite": "^0.6.3", + "is-valid-path": "0.1.1", "isomorphic-git": "^1.10.4", + "joi": "17.10.2", "js-yaml": "^3.14.1", "jshint": "^2.13.6", "jsonlint-mod-fixed": "1.7.7", diff --git a/packages/insomnia/electron-builder.config.js b/packages/insomnia/electron-builder.config.js index 39c421a8bd5..9d984811982 100644 --- a/packages/insomnia/electron-builder.config.js +++ b/packages/insomnia/electron-builder.config.js @@ -37,6 +37,10 @@ const config = { to: '.', filter: 'opensource-licenses.txt', }, + { + from: './src/utils/node-jq/bin', + to: './src/utils/node-jq/bin', + }, ], extraMetadata: { main: 'main.min.js', // Override the main path in package.json @@ -68,6 +72,7 @@ const config = { asarUnpack: [ 'node_modules/@getinsomnia/node-libcurl', ], + x64ArchFiles: '*', }, dmg: { window: { diff --git a/packages/insomnia/package.json b/packages/insomnia/package.json index 63ec4f5c7e7..47ad54eff61 100644 --- a/packages/insomnia/package.json +++ b/packages/insomnia/package.json @@ -46,6 +46,7 @@ "@stoplight/spectral-formats": "^1.5.0", "@stoplight/spectral-ruleset-bundler": "1.5.2", "@stoplight/spectral-rulesets": "^1.16.0", + "@types/strip-final-newline": "3.0.0", "apiconnect-wsdl": "1.8.31", "aws4": "^1.12.0", "axios": "^1.4.0", @@ -66,7 +67,9 @@ "html-entities": "^2.4.0", "httpsnippet": "^2.0.0", "iconv-lite": "^0.6.3", + "is-valid-path": "0.1.1", "isomorphic-git": "^1.10.4", + "joi": "17.10.2", "js-yaml": "^3.14.1", "jshint": "^2.13.6", "jsonlint-mod-fixed": "1.7.7", diff --git a/packages/insomnia/src/bin/linux/jq-linux-i386 b/packages/insomnia/src/bin/linux/jq-linux-i386 new file mode 100644 index 00000000000..5a1a76ad990 Binary files /dev/null and b/packages/insomnia/src/bin/linux/jq-linux-i386 differ diff --git a/packages/insomnia/src/bin/macOS/jq-macos-arm64 b/packages/insomnia/src/bin/macOS/jq-macos-arm64 new file mode 100644 index 00000000000..f8c5bd7a730 Binary files /dev/null and b/packages/insomnia/src/bin/macOS/jq-macos-arm64 differ diff --git a/packages/insomnia/src/bin/windows/jq-windows-i386.exe b/packages/insomnia/src/bin/windows/jq-windows-i386.exe new file mode 100644 index 00000000000..163cfb8077f Binary files /dev/null and b/packages/insomnia/src/bin/windows/jq-windows-i386.exe differ diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index 91062c7db11..a60556162af 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -26,6 +26,7 @@ import { FilterHelpModal } from '../modals/filter-help-modal'; import { showModal } from '../modals/index'; import { isKeyCombinationInRegistry } from '../settings/shortcuts'; import { normalizeIrregularWhitespace } from './normalizeIrregularWhitespace'; +import run from '../../../utils/node-jq/src/jq'; const TAB_SIZE = 4; const MAX_SIZE_FOR_LINTING = 1000000; // Around 1MB @@ -208,26 +209,34 @@ export const CodeEditor = forwardRef(({ return code; } }; - const prettifyJSON = (code: string, filter?: string) => { + const prettifyJSON = async (code: string, filter?: string) => { try { - let jsonString = code; + let jsonString: any = code; if (updateFilter && filter) { try { - const codeObj = JSON.parse(code); - const results = JSONPath({ json: codeObj, path: filter.trim() }); - jsonString = JSON.stringify(results); + if (filter.startsWith('$')) { + const codeObj = JSON.parse(code); + const results = JSONPath({ json: codeObj, path: filter.trim() }); + jsonString = JSON.stringify(results); + } + + if (filter.startsWith('.')) { + const codeObj = JSON.parse(code); + const results = await run(`${filter.trim()}`, codeObj, { input: 'json' }); + jsonString = results; + } } catch (err) { - console.log('[jsonpath] Error: ', err); - jsonString = '[]'; + console.log('JSON query Error: ', err); + jsonString = '{}'; } } - return jsonPrettify(jsonString, indentChars, autoPrettify); + return jsonPrettify(jsonString, indentChars, autoPrettify); } catch (error) { // That's Ok, just leave it return code; } }; - const maybePrettifyAndSetValue = (code?: string, forcePrettify?: boolean, filter?: string) => { + const maybePrettifyAndSetValue = async (code?: string, forcePrettify?: boolean, filter?: string) => { if (typeof code !== 'string') { console.warn('Code editor was passed non-string value', code); return; @@ -238,7 +247,7 @@ export const CodeEditor = forwardRef(({ if (mode?.includes('xml')) { code = prettifyXML(code, filter); } else if (mode?.includes('json')) { - code = prettifyJSON(code, filter); + code = await prettifyJSON(code, filter); } } // this prevents codeMirror from needlessly setting the same thing repeatedly (which has the effect of moving the user's cursor and resetting the viewport scroll: a bad user experience) @@ -252,7 +261,7 @@ export const CodeEditor = forwardRef(({ useDocBodyKeyboardShortcuts({ beautifyRequestBody: () => { if (mode?.includes('json') || mode?.includes('xml')) { - maybePrettifyAndSetValue(codeMirror.current?.getValue()); + maybePrettifyAndSetValue(codeMirror.current?.getValue()).then(); } }, }); @@ -553,7 +562,7 @@ export const CodeEditor = forwardRef(({ type="text" title="Filter response body" defaultValue={filter || ''} - placeholder={mode?.includes('json') ? '$.store.books[*].author' : '/store/books/author'} + placeholder={mode?.includes('json') ? 'jq: .store.book[].author or JSONPath: $.store.books[*].author' : '/store/books/author'} onKeyDown={createKeybindingsHandler({ 'Enter': () => { const filter = inputRef.current?.value; diff --git a/packages/insomnia/src/ui/components/modals/filter-help-modal.tsx b/packages/insomnia/src/ui/components/modals/filter-help-modal.tsx index 459bd4ceea0..571718ca7b9 100644 --- a/packages/insomnia/src/ui/components/modals/filter-help-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/filter-help-modal.tsx @@ -23,11 +23,25 @@ const HelpExamples: FC<{ helpExamples: HelpExample[] }> = ({ helpExamples }) => ); -const JSONPathHelp: FC = () => ( +const JqHelper: FC = () => ( +

+ Use Jq to filter the response body. Here are some examples that you might use on a book store API: +

+
+ +

Use JSONPath to filter the response body. Here are some examples that you might use on a book store API:

+
( ]} />

- Note that there's no standard for JSONPath. Insomnia uses jsonpath-plus. + Insomnia supports both jq and JSONPath. + Note that there's no standard for JSONPath.

); @@ -88,7 +103,7 @@ export const FilterHelpModal = forwardRef((_, return ( Response Filtering Help - {isJSON ? : null} + {isJSON ? : null} {isXPath ? : null} ); diff --git a/packages/insomnia/src/ui/components/templating/local-template-tags.ts b/packages/insomnia/src/ui/components/templating/local-template-tags.ts index e052585ff1a..eb024005428 100644 --- a/packages/insomnia/src/ui/components/templating/local-template-tags.ts +++ b/packages/insomnia/src/ui/components/templating/local-template-tags.ts @@ -13,6 +13,7 @@ import { Response } from '../../../models/response'; import { TemplateTag } from '../../../plugins'; import { PluginTemplateTag } from '../../../templating/extensions'; import { invariant } from '../../../utils/invariant'; +import run from '../../../utils/node-jq/src/jq'; import { buildQueryStringFromParams, joinUrlAndQueryString, smartEncodeUrl } from '../../../utils/url/querystring'; const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ @@ -160,18 +161,25 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ ], }, { - displayName: 'JSONPath Filter', - help: 'Some OS functions return objects. Use JSONPath queries to extract desired values.', + displayName: 'jq / JSONPath Filter', + help: 'Some OS functions return objects. Use jq or JSONPath queries to extract desired values.', hide: args => !['userInfo', 'cpus'].includes(args[0].value + ''), type: 'string', }, ], - run(_context, fnName: 'arch' | 'cpus', filter) { - let value = os[fnName](); + async run(_context, fnName: 'arch' | 'cpus', filter) { + let value: any = os[fnName](); - if (JSONPath && ['userInfo', 'cpus'].includes(fnName)) { + if (jq && ['userInfo', 'cpus'].includes(fnName)) { try { - value = JSONPath({ json: value, path: filter })[0]; + if (filter.indexOf('$') === 0) { + value = JSONPath({ json: value, path: filter })[0]; + } + + if (filter.indexOf('.') === 0) { + value = await run(`${filter.trim()}`, value, { input: 'json' }); + value = JSON.parse(value); + } } catch (err) { } } @@ -250,6 +258,46 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ }, }, }, + { + templateTag: { + displayName: 'jq', + name: 'jq', + description: 'pull data from JSON strings with jq', + args: [ + { + displayName: 'JSON string', + type: 'string', + }, + { + displayName: 'jq Filter', + encoding: 'base64', // So it doesn't cause syntax errors + type: 'string', + }, + ], + async run(_context, jsonString, filter) { + let body; + try { + body = JSON.parse(jsonString); + } catch (err) { + throw new Error(`Invalid JSON: ${err.message}`); + } + + let results: any; + try { + results = await run(`${filter.trim()}`, body, { input: 'json' }); + results = JSON.parse(results); + } catch (err) { + throw new Error(`Invalid jq query: ${filter}`); + } + + if (results.length === 0) { + throw new Error(`jq query returned no results: ${filter}`); + } + + return results[0]; + }, + }, + }, { templateTag: { displayName: 'JSONPath', @@ -274,7 +322,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ throw new Error(`Invalid JSON: ${err.message}`); } - let results; + let results: any; try { results = JSONPath({ json: body, path: filter }); } catch (err) { @@ -423,7 +471,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ // If we don't have a key, default to request ID. // We do this because we may render the prompt multiple times per request. - // We cache it under the requestId so it only prompts once. We then clear + // We cache it under the requestId, so it only prompts once. We then clear // the cache in a response hook when the request is sent. const titleHash = crypto .createHash('md5') @@ -512,7 +560,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ displayName: args => { switch (args[0].value) { case 'body': - return 'Filter (JSONPath or XPath)'; + return 'Filter (jq or JSONPath or XPath)'; case 'header': return 'Header Name'; default: @@ -606,7 +654,7 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ } - // Make sure we only send the request once per render so we don't have infinite recursion + // Make sure we only send the request once per render, so we don't have infinite recursion const requestChain = context.context.getExtraInfo?.('requestChain') || []; if (requestChain.some((id: any) => id === request._id)) { console.log('[response tag] Preventing recursive render'); @@ -677,9 +725,9 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ body = bodyBuffer.toString(); } - if (sanitizedFilter.indexOf('$') === 0) { + if (sanitizedFilter.indexOf('.') === 0 || sanitizedFilter.indexOf('$') === 0) { let bodyJSON; - let results; + let results: any; try { bodyJSON = JSON.parse(body); @@ -687,10 +735,25 @@ const localTemplatePlugins: { templateTag: PluginTemplateTag }[] = [ throw new Error(`Invalid JSON: ${err.message}`); } - try { - results = JSONPath({ json: bodyJSON, path: sanitizedFilter }); - } catch (err) { - throw new Error(`Invalid JSONPath query: ${sanitizedFilter}`); + if (sanitizedFilter.indexOf('.') === 0) { + try { + results = await run (`${sanitizedFilter.trim ()}`, bodyJSON, { input: 'json' }); + results = JSON.parse (results); + } catch (err) { + throw new Error (`Invalid jq query: ${sanitizedFilter}`); + } + } + + if (sanitizedFilter.indexOf('$') === 0) { + try { + results = JSONPath({ json: bodyJSON, path: sanitizedFilter }); + } catch (err) { + throw new Error(`Invalid JSONPath query: ${sanitizedFilter}`); + } + } + + if (typeof results === 'object') { + return JSON.stringify(results); } if (results.length === 0) { diff --git a/packages/insomnia/src/utils/node-jq/LICENSE.md b/packages/insomnia/src/utils/node-jq/LICENSE.md new file mode 100644 index 00000000000..7fb923747b2 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) David Sancho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/insomnia/src/utils/node-jq/bin/linux/jq-linux-i386 b/packages/insomnia/src/utils/node-jq/bin/linux/jq-linux-i386 new file mode 100755 index 00000000000..5a1a76ad990 Binary files /dev/null and b/packages/insomnia/src/utils/node-jq/bin/linux/jq-linux-i386 differ diff --git a/packages/insomnia/src/utils/node-jq/bin/macOS/jq-macos-arm64 b/packages/insomnia/src/utils/node-jq/bin/macOS/jq-macos-arm64 new file mode 100755 index 00000000000..f8c5bd7a730 Binary files /dev/null and b/packages/insomnia/src/utils/node-jq/bin/macOS/jq-macos-arm64 differ diff --git a/packages/insomnia/src/utils/node-jq/bin/windows/jq-windows-i386.exe b/packages/insomnia/src/utils/node-jq/bin/windows/jq-windows-i386.exe new file mode 100755 index 00000000000..163cfb8077f Binary files /dev/null and b/packages/insomnia/src/utils/node-jq/bin/windows/jq-windows-i386.exe differ diff --git a/packages/insomnia/src/utils/node-jq/src/command.d.ts b/packages/insomnia/src/utils/node-jq/src/command.d.ts new file mode 100644 index 00000000000..f5e31ecd111 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/command.d.ts @@ -0,0 +1,15 @@ +import { PartialOptions } from './options'; + +export const FILTER_UNDEFINED_ERROR = + 'node-jq: invalid filter argument supplied: "undefined"'; +export const INPUT_JSON_UNDEFINED_ERROR = + 'node-jq: invalid json object argument supplied: "undefined"'; +export const INPUT_STRING_ERROR = + 'node-jq: invalid json string argument supplied'; + +interface ICommand { + command: string; + args: string[]; + stdin: string; +} +export function commandFactory(filter: string, json: any, options?: PartialOptions, jqPath?: string): ICommand; diff --git a/packages/insomnia/src/utils/node-jq/src/command.js b/packages/insomnia/src/utils/node-jq/src/command.js new file mode 100644 index 00000000000..80e3c5b5ad5 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/command.js @@ -0,0 +1,84 @@ +import * as Joi from 'joi'; +import path from 'path'; + +import { isDevelopment, isLinux, isMac, isWindows } from '../../../common/constants'; +import { + optionsSchema, + parseOptions, + preSpawnSchema, + spawnSchema, +} from './options'; + +// set jq path +let JQ_PATH = ''; +const macBinPath = 'src/utils/node-jq/bin/macOS/jq-macos-arm64'; +const windowsBinPath = 'src/utils/node-jq/bin/macOS/jq-windows-i386.exe'; +const linuxBinPath = 'src/utils/node-jq/bin/macOS/jq-linux-i386'; +if (isMac()) { + JQ_PATH = isDevelopment() ? path.resolve('../insomnia/', macBinPath) : path.resolve(process.resourcesPath, macBinPath); +} + +if (isWindows()) { + JQ_PATH = isDevelopment() ? path.resolve('../insomnia/', windowsBinPath) : path.resolve(process.resourcesPath, windowsBinPath); +} + +if (isLinux()) { + JQ_PATH = isDevelopment() ? path.resolve('../insomnia/', linuxBinPath) : path.resolve(process.resourcesPath, linuxBinPath); +} + +console.log(`Loading jq path: ${JQ_PATH}`); + +const NODE_JQ_ERROR_TEMPLATE = + 'node-jq: invalid {#label} ' + + 'argument supplied{if(#label == "path" && #type == "json", " (not a .json file)", "")}' + + '{if(#label == "path" && #type == "path", " (not a valid path)", "")}: ' + + '"{if(#value != undefined, #value, "undefined")}"'; + +const messages = { + 'any.invalid': NODE_JQ_ERROR_TEMPLATE, + 'any.required': NODE_JQ_ERROR_TEMPLATE, + 'string.base': NODE_JQ_ERROR_TEMPLATE, + 'string.empty': NODE_JQ_ERROR_TEMPLATE, +}; + +const validateArguments = (filter, json, options) => { + const context = { filter, json }; + const validatedOptions = Joi.attempt(options, optionsSchema); + const validatedPreSpawn = Joi.attempt( + context, + preSpawnSchema.tailor(validatedOptions.input), + { messages, errors: { wrap: { label: '' } } } + ); + const validatedArgs = parseOptions( + validatedOptions, + validatedPreSpawn.filter, + validatedPreSpawn.json + ); + const validatedSpawn = Joi.attempt( + {}, + spawnSchema.tailor(validatedOptions.input), + { context: { ...validatedPreSpawn, options: validatedOptions } } + ); + + if (validatedOptions.input === 'file') { + return { + args: validatedArgs, + stdin: validatedSpawn.stdin, + }; + } + return { + args: validatedArgs, + stdin: validatedSpawn.stdin, + }; +}; + +export const commandFactory = (filter, json, options = {}, jqPath) => { + const command = jqPath ? path.join(jqPath, './jq') : JQ_PATH; + const result = validateArguments(filter, json, options); + + return { + command, + args: result.args, + stdin: result.stdin, + }; +}; diff --git a/packages/insomnia/src/utils/node-jq/src/exec.d.ts b/packages/insomnia/src/utils/node-jq/src/exec.d.ts new file mode 100644 index 00000000000..8ab68f436ea --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/exec.d.ts @@ -0,0 +1 @@ +export default function(command: string, args: string[], stdin: string, cwd?: string): Promise; diff --git a/packages/insomnia/src/utils/node-jq/src/exec.js b/packages/insomnia/src/utils/node-jq/src/exec.js new file mode 100644 index 00000000000..10ea81035b0 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/exec.js @@ -0,0 +1,51 @@ +import childProcess from 'child_process'; +import stripFinalNewline from 'strip-final-newline'; + +const TEN_MEBIBYTE = 1024 * 1024 * 10; + +const exec = (command, args, stdin, cwd) => { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + + const spawnOptions = { maxBuffer: TEN_MEBIBYTE, cwd, env: {} }; + + const process = childProcess.spawn(command, args, spawnOptions); + + // All of these handlers can close the Promise, so guard against rejecting it twice. + let promiseAlreadyRejected = false; + process.on('close', code => { + if (!promiseAlreadyRejected) { + promiseAlreadyRejected = true; + if (code !== 0) { + return reject(Error(stderr)); + } else { + return resolve(stripFinalNewline(stdout)); + } + } + }); + + if (stdin) { + process.stdin.on('error', err => { + if (!promiseAlreadyRejected) { + promiseAlreadyRejected = true; + return reject(err); + } + }); + process.stdin.setEncoding('utf-8'); + process.stdin.write(stdin); + process.stdin.end(); + } + + process.stdout.setEncoding('utf-8'); + process.stdout.on('data', data => { + stdout += data; + }); + + process.stderr.on('data', data => { + stderr += data; + }); + }); +}; + +export default exec; diff --git a/packages/insomnia/src/utils/node-jq/src/jq.d.ts b/packages/insomnia/src/utils/node-jq/src/jq.d.ts new file mode 100644 index 00000000000..3300e197ea7 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/jq.d.ts @@ -0,0 +1,3 @@ +import { PartialOptions } from './options'; + +export function run(filter: string, json: any, options?: PartialOptions, jqPath?: string, cwd?: string): Promise; diff --git a/packages/insomnia/src/utils/node-jq/src/jq.js b/packages/insomnia/src/utils/node-jq/src/jq.js new file mode 100644 index 00000000000..b9505e213d5 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/jq.js @@ -0,0 +1,31 @@ +import { commandFactory } from './command'; +import exec from './exec'; + +const run = (filter, json, options = {}, jqPath, cwd) => { + return new Promise((resolve, reject) => { + const { command, args, stdin } = commandFactory( + filter, + json, + options, + jqPath + ); + + exec(command, args, stdin, cwd) + .then(stdout => { + if (options.output === 'json') { + let result; + try { + result = JSON.parse(stdout); + } catch (error) { + result = stdout; + } + return resolve(result); + } else { + return resolve(stdout); + } + }) + .catch(reject); + }); +}; + +export default run; diff --git a/packages/insomnia/src/utils/node-jq/src/options.d.ts b/packages/insomnia/src/utils/node-jq/src/options.d.ts new file mode 100644 index 00000000000..2f657f17936 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/options.d.ts @@ -0,0 +1,17 @@ +import * as Joi from 'joi'; + +export declare const optionsSchema: Joi.SchemaLike; +export declare const preSpawnSchema: Joi.SchemaLike; +export declare const spawnSchema: Joi.SchemaLike; +export declare function parseOptions(options: PartialOptions, filter: string, json: any): any; +export declare const optionDefaults: IOptions; +interface IOptions { + color: boolean; + input: string; + locations: string[]; + output: string; + raw: boolean; + slurp: boolean; + sort: boolean; +} +export type PartialOptions = Partial; diff --git a/packages/insomnia/src/utils/node-jq/src/options.js b/packages/insomnia/src/utils/node-jq/src/options.js new file mode 100644 index 00000000000..32d4e17fe6c --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/options.js @@ -0,0 +1,102 @@ +import Joi from 'joi'; + +import { validateJSONPath } from './utils'; + +function createBooleanSchema(name, value) { + return Joi.string().when(`${name}`, { + is: Joi.boolean().required().valid(true), + then: Joi.string().default(value), + }); +} + +const strictBoolean = Joi.boolean().default(false).strict(); +const path = Joi.any() + .custom((value, helpers) => { + try { + validateJSONPath(value); + return value; + } catch (e) { + const errorType = e.message.includes('.json') ? 'json' : 'path'; + return helpers.error('any.invalid', { type: errorType }); + } + }, 'path validation').required().error(errors => { + errors.forEach(error => { + if (error.value === undefined) { + error.local.type = 'path'; + } + }); + return errors; + }); + +export const optionsSchema = Joi.object({ + color: strictBoolean, + input: Joi.string().default('file').valid('file', 'json', 'string'), + locations: Joi.array().items(Joi.string()).default([]), + output: Joi.string().default('pretty').valid('string', 'compact', 'pretty', 'json'), + raw: strictBoolean, + slurp: strictBoolean, + sort: strictBoolean, +}); + +export const preSpawnSchema = Joi.object({ + filter: Joi.string().allow('', null).required(), + json: Joi.any().alter({ + file: schema => { + return schema.when('/json', + { + is: Joi.array().required(), + then: Joi.array().items(path), + otherwise: path, + }).label('path'); + }, + json: schema => Joi.alternatives().try( + Joi.array(), + Joi.object().allow('', null).required().label('json object') + ).required(), + string: schema => Joi.string().required().label('json string'), + }), +}); + +export const spawnSchema = Joi.object({ + args: Joi.object({ + color: createBooleanSchema('$options.color', '--color-output'), + input: Joi.any().alter({ + file: schema => schema.when('$json', { + is: [Joi.array().items(Joi.string()), Joi.string()], + then: Joi.array().default(Joi.ref('$json', { + adjust: value => { + return [].concat(value); + }, + })), + }), + }), + locations: Joi.ref('$options.locations'), + output: Joi.string().when('$options.output', { + is: Joi.string().required().valid('string', 'compact'), + then: Joi.string().default('--compact-output'), + }), + raw: createBooleanSchema('$options.raw', '-r'), + slurp: createBooleanSchema('$options.slurp', '--slurp'), + sort: createBooleanSchema('$options.sort', '--sort-keys'), + }).default(), + stdin: Joi.string().default('').alter({ + json: schema => schema.default(Joi.ref('$json', { + adjust: value => JSON.stringify(value), + })), + string: schema => schema.default(Joi.ref('$json')), + }), +}); + +export const parseOptions = (options = {}, filter, json) => { + const context = { filter, json, options }; + const validatedSpawn = Joi.attempt({}, spawnSchema.tailor(options.input), { context }); + + if (options.input === 'file') { + return Object.keys(validatedSpawn.args).filter(key => key !== 'input') + .reduce((list, key) => list.concat(validatedSpawn.args[key]), []) + .concat(filter, json); + } + return Object.values(validatedSpawn.args).concat(filter); +}; + +export const optionDefaults = Joi.attempt({}, optionsSchema); diff --git a/packages/insomnia/src/utils/node-jq/src/utils.d.ts b/packages/insomnia/src/utils/node-jq/src/utils.d.ts new file mode 100644 index 00000000000..f1b4e7f2037 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/utils.d.ts @@ -0,0 +1,8 @@ +export const INVALID_PATH_ERROR = + 'node-jq: invalid path argument supplied (not a valid path)'; +export const INVALID_JSON_PATH_ERROR = + 'node-jq: invalid path argument supplied (not a .json file)'; + +export function isJSONPath(path: any): boolean; + +export function validateJSONPath(path: any): boolean; diff --git a/packages/insomnia/src/utils/node-jq/src/utils.js b/packages/insomnia/src/utils/node-jq/src/utils.js new file mode 100644 index 00000000000..fe822daf178 --- /dev/null +++ b/packages/insomnia/src/utils/node-jq/src/utils.js @@ -0,0 +1,22 @@ +import isPathValid from 'is-valid-path'; + +export const INVALID_PATH_ERROR = + 'node-jq: invalid path argument supplied (not a valid path)'; +export const INVALID_JSON_PATH_ERROR = + 'node-jq: invalid path argument supplied (not a .json file)'; + +export const isJSONPath = path => { + return /\.json|.jsonl$/.test(path); +}; + +export const validateJSONPath = path => { + if (!isPathValid(path)) { + throw new Error(`${INVALID_PATH_ERROR}: "${path}"`); + } + + if (!isJSONPath(path)) { + throw new Error(`${INVALID_JSON_PATH_ERROR}: "${path === '' ? '' : path}"`); + } + + return true; +}; diff --git a/packages/insomnia/tsconfig.build.json b/packages/insomnia/tsconfig.build.json index c1215ce6d27..c8621feb15f 100644 --- a/packages/insomnia/tsconfig.build.json +++ b/packages/insomnia/tsconfig.build.json @@ -9,7 +9,7 @@ "module": "ESNext", "skipLibCheck": true, "strictNullChecks": true, - "types": ["node", "vite/client"], + "types": ["node", "vite/client", "is-valid-path"], }, "include": [ "**/*.d.ts",