diff --git a/package-lock.json b/package-lock.json index 0c24a8003bb..fe0cca26fb6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20275,6 +20275,12 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jq-wasm": { + "version": "1.1.0-jq-1.8.1", + "resolved": "https://registry.npmjs.org/jq-wasm/-/jq-wasm-1.1.0-jq-1.8.1.tgz", + "integrity": "sha512-lWfu34lpDFIygOYcL5TzxhZIApDR9iR5XywcVoyUAZ6jlQrj8HKHOKeCcHgUm2dE9RVdbP3eqNAKGLuj+k4seQ==", + "license": "MIT" + }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -30193,6 +30199,7 @@ "i18next": "24.1.2", "idb": "^7.0.0", "immer": "^9.0.15", + "jq-wasm": "^1.1.0-jq-1.8.1", "js-yaml": "^4.1.0", "jsesc": "^3.0.2", "jshint": "^2.13.6", diff --git a/packages/bruno-app/package.json b/packages/bruno-app/package.json index e5c95f83f70..3e0025b8d0b 100644 --- a/packages/bruno-app/package.json +++ b/packages/bruno-app/package.json @@ -45,6 +45,7 @@ "i18next": "24.1.2", "idb": "^7.0.0", "immer": "^9.0.15", + "jq-wasm": "1.1.0-jq-1.8.1", "js-yaml": "^4.1.0", "jsesc": "^3.0.2", "jshint": "^2.13.6", diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js index 5320b419576..3776ea63015 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/QueryResultFilter/index.js @@ -4,7 +4,7 @@ import { useRef } from 'react'; import { useState } from 'react'; import { Tooltip as ReactInfotip } from 'react-tooltip'; -const QueryResultFilter = ({ filter, onChange, mode }) => { +const QueryResultFilter = ({ filter, onChange, mode, filterType, onFilterTypeChange, jqError }) => { const inputRef = useRef(null); const [isExpanded, toggleExpand] = useState(false); @@ -19,9 +19,19 @@ const QueryResultFilter = ({ filter, onChange, mode }) => { } }; + const handleFilterTypeChange = (type) => { + if (type === filterType) return; + onFilterTypeChange(type); + // Clear input when switching filter type + onChange({ target: { value: '' } }); + if (inputRef?.current) { + inputRef.current.value = ''; + } + }; + const infotipText = useMemo(() => { if (mode.includes('json')) { - return 'Filter with JSONPath'; + return filterType === 'jq' ? 'Filter with jq' : 'Filter with JSONPath'; } if (mode.includes('xml')) { @@ -29,11 +39,11 @@ const QueryResultFilter = ({ filter, onChange, mode }) => { } return null; - }, [mode]); + }, [mode, filterType]); const placeholderText = useMemo(() => { if (mode.includes('json')) { - return '$.store.books..author'; + return filterType === 'jq' ? '.store.books[].author' : '$.store.books..author'; } if (mode.includes('xml')) { @@ -41,13 +51,29 @@ const QueryResultFilter = ({ filter, onChange, mode }) => { } return null; - }, [mode]); + }, [mode, filterType]); return (
{infotipText && !isExpanded && } + {isExpanded && mode.includes('json') && ( +
+ + +
+ )} { autoCorrect="off" autoCapitalize="off" spellCheck="false" - className={`block ml-14 p-2 py-1 transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${ + className={`block ${isExpanded && mode.includes('json') ? 'ml-0' : 'ml-14'} p-2 py-1 transition-all duration-200 ease-in-out border border-gray-300 rounded-md ${ isExpanded ? 'w-full opacity-100 pointer-events-auto' : 'w-[0] opacity-0' }`} onChange={onChange} @@ -66,6 +92,9 @@ const QueryResultFilter = ({ filter, onChange, mode }) => {
{isExpanded ? : }
+ {isExpanded && jqError && ( +
{jqError}
+ )}
); }; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js index 9ed72277fd5..453a1408fb0 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/StyledWrapper.js @@ -58,6 +58,55 @@ const StyledWrapper = styled.div` outline: none; } } + + .jq-error { + position: absolute; + bottom: 100%; + right: 0; + margin-bottom: 4px; + padding: 2px 8px; + font-size: 11px; + color: ${(props) => props.theme.colors.text.danger}; + background-color: ${(props) => props.theme.background.base}; + border: solid 1px ${(props) => props.theme.colors.text.danger}; + border-radius: ${(props) => props.theme.border.radius.sm}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + + .filter-type-toggle { + flex-shrink: 0; + + .toggle-btn { + font-size: 11px; + padding: 2px 8px; + border: solid 1px ${(props) => props.theme.border.border2}; + background-color: ${(props) => props.theme.background.mantle}; + color: ${(props) => props.theme.colors.text.muted}; + cursor: pointer; + white-space: nowrap; + + &:first-child { + border-radius: 4px 0 0 4px; + } + + &:last-child { + border-radius: 0 4px 4px 0; + margin-left: -1px; + } + + &.active { + background-color: ${(props) => props.theme.background.base}; + color: ${(props) => props.theme.colors.text.yellow}; + border-color: ${(props) => props.theme.colors.text.yellow}; + font-weight: 600; + position: relative; + z-index: 1; + } + } + } } `; diff --git a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js index b6f40be58d2..438ed67d1b5 100644 --- a/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js +++ b/packages/bruno-app/src/components/ResponsePane/QueryResult/index.js @@ -1,7 +1,8 @@ import { debounce } from 'lodash'; import { useTheme } from 'providers/Theme/index'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { formatResponse, getContentType } from 'utils/common'; +import { runJqFilter } from 'utils/common/jq-service'; import { getDefaultResponseFormat, detectContentTypeFromBase64 } from 'utils/response'; import LargeResponseWarning from '../LargeResponseWarning'; import QueryResultFilter from './QueryResultFilter'; @@ -102,6 +103,9 @@ const QueryResult = ({ }) => { const contentType = getContentType(headers); const [filter, setFilter] = useState(null); + const [filterType, setFilterType] = useState('jsonpath'); + const [jqResult, setJqResult] = useState(null); + const [jqError, setJqError] = useState(null); const [showLargeResponse, setShowLargeResponse] = useState(false); const { displayedTheme } = useTheme(); @@ -124,14 +128,46 @@ const QueryResult = ({ return detectContentTypeFromBase64(dataBuffer); }, [dataBuffer, isLargeResponse]); + // Run jq filter asynchronously when filterType is 'jq' + useEffect(() => { + if (filterType !== 'jq' || !filter) { + setJqResult(null); + setJqError(null); + return; + } + let cancelled = false; + setJqResult(null); + setJqError(null); + runJqFilter(data, filter) + .then((result) => { + if (!cancelled) { + setJqResult(result); + setJqError(null); + } + }) + .catch((err) => { + if (!cancelled) { + setJqResult(null); + setJqError(err.message); + } + }); + return () => { cancelled = true; }; + }, [data, filter, filterType]); + const formattedData = useMemo( () => { if (isLargeResponse && !showLargeResponse) { return ''; } - return formatResponse(data, dataBuffer, selectedFormat, filter); + // For jq mode with an active filter, use the async result + if (filterType === 'jq' && filter) { + return jqResult ?? formatResponse(data, dataBuffer, selectedFormat, null); + } + // For JSONPath mode, pass filter to formatResponse as before + const jsonPathFilter = filterType === 'jsonpath' ? filter : null; + return formatResponse(data, dataBuffer, selectedFormat, jsonPathFilter); }, - [data, dataBuffer, selectedFormat, filter, isLargeResponse, showLargeResponse] + [data, dataBuffer, selectedFormat, filter, filterType, jqResult, isLargeResponse, showLargeResponse] ); const debouncedResultFilterOnChange = debounce((e) => { @@ -213,7 +249,14 @@ const QueryResult = ({ /> {queryFilterEnabled && ( - + )} diff --git a/packages/bruno-app/src/utils/common/jq-service.js b/packages/bruno-app/src/utils/common/jq-service.js new file mode 100644 index 00000000000..23ad966a18f --- /dev/null +++ b/packages/bruno-app/src/utils/common/jq-service.js @@ -0,0 +1,14 @@ +import * as jq from 'jq-wasm'; + +export async function runJqFilter(data, filter) { + try { + const result = await jq.raw(data, filter); + if (result.exitCode !== 0) { + throw new Error(result.stderr || 'jq error'); + } + return result.stdout; + } catch (e) { + console.warn('Could not apply jq filter:', e.message); + throw e; + } +} diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 60612751365..d0a52011e22 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -72,7 +72,7 @@ const contentSecurityPolicy = [ 'connect-src \'self\' https://*.posthog.com', 'font-src \'self\' https: data:;', 'frame-src data:', - 'script-src \'self\' data:', + 'script-src \'self\' data: \'wasm-unsafe-eval\'', // this has been commented out to make oauth2 work // "form-action 'none'", // we make an exception and allow http for images so that