diff --git a/packages/kbn-monaco/src/console/parser.js b/packages/kbn-monaco/src/console/parser.js index e7369444ab85a..881370f5b9fac 100644 --- a/packages/kbn-monaco/src/console/parser.js +++ b/packages/kbn-monaco/src/console/parser.js @@ -37,24 +37,7 @@ export const createParser = () => { requestStartOffset = at - 1; requests.push({ startOffset: requestStartOffset }); }, - addRequestMethod = function(method) { - const lastRequest = getLastRequest(); - lastRequest.method = method; - requests.push(lastRequest); - requestEndOffset = at - 1; - }, - addRequestUrl = function(url) { - const lastRequest = getLastRequest(); - lastRequest.url = url; - requests.push(lastRequest); - requestEndOffset = at - 1; - }, - addRequestData = function(data) { - const lastRequest = getLastRequest(); - const dataArray = lastRequest.data || []; - dataArray.push(data); - lastRequest.data = dataArray; - requests.push(lastRequest); + updateRequestEnd = function () { requestEndOffset = at - 1; }, addRequestEnd = function() { @@ -409,17 +392,17 @@ export const createParser = () => { request = function () { white(); addRequestStart(); - const parsedMethod = method(); - addRequestMethod(parsedMethod); + method(); + updateRequestEnd(); strictWhite(); - const parsedUrl = url(); - addRequestUrl(parsedUrl); + url(); + updateRequestEnd(); strictWhite(); // advance to one new line newLine(); strictWhite(); if (ch == '{') { - const parsedObject = object(); - addRequestData(parsedObject); + object(); + updateRequestEnd(); } // multi doc request strictWhite(); // advance to one new line @@ -427,8 +410,8 @@ export const createParser = () => { strictWhite(); while (ch == '{') { // another object - const parsedObject = object(); - addRequestData(parsedObject); + object(); + updateRequestEnd(); strictWhite(); newLine(); strictWhite(); diff --git a/packages/kbn-monaco/src/console/parser.test.ts b/packages/kbn-monaco/src/console/parser.test.ts index 417fdd12e2c18..1529d907317cf 100644 --- a/packages/kbn-monaco/src/console/parser.test.ts +++ b/packages/kbn-monaco/src/console/parser.test.ts @@ -25,9 +25,7 @@ describe('console parser', () => { const { requests, errors } = parser(input) as ConsoleParserResult; expect(requests.length).toBe(1); expect(errors.length).toBe(0); - const { method, url, startOffset, endOffset } = requests[0]; - expect(method).toBe('GET'); - expect(url).toBe('_search'); + const { startOffset, endOffset } = requests[0]; // the start offset of the request is the beginning of the string expect(startOffset).toBe(0); // the end offset of the request is the end of the string @@ -38,6 +36,10 @@ describe('console parser', () => { const input = 'GET _search\nPOST _test_index'; const { requests } = parser(input) as ConsoleParserResult; expect(requests.length).toBe(2); + expect(requests[0].startOffset).toBe(0); + expect(requests[0].endOffset).toBe(11); + expect(requests[1].startOffset).toBe(12); + expect(requests[1].endOffset).toBe(28); }); it('parses a request with a request body', () => { @@ -45,15 +47,8 @@ describe('console parser', () => { 'GET _search\n' + '{\n' + ' "query": {\n' + ' "match_all": {}\n' + ' }\n' + '}'; const { requests } = parser(input) as ConsoleParserResult; expect(requests.length).toBe(1); - const { method, url, data } = requests[0]; - expect(method).toBe('GET'); - expect(url).toBe('_search'); - expect(data).toEqual([ - { - query: { - match_all: {}, - }, - }, - ]); + const { startOffset, endOffset } = requests[0]; + expect(startOffset).toBe(0); + expect(endOffset).toBe(52); }); }); diff --git a/packages/kbn-monaco/src/console/types.ts b/packages/kbn-monaco/src/console/types.ts index 346bd0e6beeeb..4ad7d28ac778b 100644 --- a/packages/kbn-monaco/src/console/types.ts +++ b/packages/kbn-monaco/src/console/types.ts @@ -14,9 +14,6 @@ export interface ErrorAnnotation { export interface ParsedRequest { startOffset: number; endOffset?: number; - method: string; - url?: string; - data?: Array>; } export interface ConsoleParserResult { errors: ErrorAnnotation[]; diff --git a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts index 5a7cc19ba9678..4e13583d09aef 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/monaco_editor_actions_provider.ts @@ -11,6 +11,7 @@ import { debounce, range } from 'lodash'; import { ConsoleParsedRequestsProvider, getParsedRequestsProvider, monaco } from '@kbn/monaco'; import { i18n } from '@kbn/i18n'; import { toMountPoint } from '@kbn/react-kibana-mount'; +import { XJson } from '@kbn/es-ui-shared-plugin/public'; import { isQuotaExceededError } from '../../../../services/history'; import { DEFAULT_VARIABLES } from '../../../../../common/constants'; import { getStorage, StorageKeys } from '../../../../services'; @@ -33,13 +34,14 @@ import { replaceRequestVariables, SELECTED_REQUESTS_CLASSNAME, shouldTriggerSuggestions, - stringifyRequest, trackSentRequests, + getRequestFromEditor, } from './utils'; import type { AdjustedParsedRequest } from './types'; import { StorageQuotaError } from '../../../components/storage_quota_error'; import { ContextValue } from '../../../contexts'; +import { containsComments, indentData } from './utils/requests_utils'; const AUTO_INDENTATION_ACTION_LABEL = 'Apply indentations'; const TRIGGER_SUGGESTIONS_ACTION_LABEL = 'Trigger suggestions'; @@ -48,6 +50,7 @@ const DEBOUNCE_HIGHLIGHT_WAIT_MS = 200; const DEBOUNCE_AUTOCOMPLETE_WAIT_MS = 500; const INSPECT_TOKENS_LABEL = 'Inspect tokens'; const INSPECT_TOKENS_HANDLER_ID = 'editor.action.inspectTokens'; +const { collapseLiteralStrings } = XJson; export class MonacoEditorActionsProvider { private parsedRequestsProvider: ConsoleParsedRequestsProvider; @@ -173,12 +176,12 @@ export class MonacoEditorActionsProvider { const selectedRequests: AdjustedParsedRequest[] = []; for (const [index, parsedRequest] of parsedRequests.entries()) { const requestStartLineNumber = getRequestStartLineNumber(parsedRequest, model); - const requestEndLineNumber = getRequestEndLineNumber( + const requestEndLineNumber = getRequestEndLineNumber({ parsedRequest, + nextRequest: parsedRequests.at(index + 1), model, - index, - parsedRequests - ); + startLineNumber, + }); if (requestStartLineNumber > endLineNumber) { // request is past the selection, no need to check further requests break; @@ -198,13 +201,31 @@ export class MonacoEditorActionsProvider { } public async getRequests() { + const model = this.editor.getModel(); + if (!model) { + return []; + } + const parsedRequests = await this.getSelectedParsedRequests(); - const stringifiedRequests = parsedRequests.map((parsedRequest) => - stringifyRequest(parsedRequest) - ); + const stringifiedRequests = parsedRequests.map((parsedRequest) => { + const { startLineNumber, endLineNumber } = parsedRequest; + const requestTextFromEditor = getRequestFromEditor(model, startLineNumber, endLineNumber); + if (requestTextFromEditor && requestTextFromEditor.data.length > 0) { + requestTextFromEditor.data = requestTextFromEditor.data.map((dataString) => { + if (containsComments(dataString)) { + // parse and stringify to remove comments + dataString = indentData(dataString); + } + return collapseLiteralStrings(dataString); + }); + } + return requestTextFromEditor; + }); // get variables values const variables = getStorage().get(StorageKeys.VARIABLES, DEFAULT_VARIABLES); - return stringifiedRequests.map((request) => replaceRequestVariables(request, variables)); + return stringifiedRequests + .filter(Boolean) + .map((request) => replaceRequestVariables(request!, variables)); } public async getCurl(elasticsearchBaseUrl: string): Promise { @@ -388,12 +409,6 @@ export class MonacoEditorActionsProvider { return null; } - // if the current request doesn't have a method, the request is not valid - // and shouldn't have an autocomplete type - if (!currentRequest.method) { - return null; - } - // if not on the 1st line of the request, suggest request body return AutocompleteType.BODY; } diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts index bbfa33d84e70e..5c6a9885c3627 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/autocomplete_utils.ts @@ -58,7 +58,7 @@ export const getDocumentationLinkFromAutocomplete = ( * Helper function that filters out suggestions without a name. */ const filterTermsWithoutName = (terms: ResultTerm[]): ResultTerm[] => - terms.filter((term) => term.name !== undefined); + terms.filter((term) => term.name !== undefined && term.name !== ''); /* * This function returns an array of completion items for the request method diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/constants.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/constants.ts index 0f4664be48994..7e1ebbc85a50a 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/constants.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/constants.ts @@ -40,6 +40,10 @@ export const END_OF_URL_TOKEN = '__url_path_end__'; * In this case autocomplete suggestions should be triggered for an url. */ export const methodWhitespaceRegex = /^\s*(GET|POST|PUT|PATCH|DELETE)\s+$/i; +/* + * This regex matches a string that starts with a method (optional whitespace before the method) + */ +export const startsWithMethodRegex = /^\s*(GET|POST|PUT|PATCH|DELETE)/i; /* * This regex matches a string that has * a method and some parts of an url ending with a slash, a question mark or an equals sign, diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts index 0997aa682b630..bc402875633a0 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/index.ts @@ -18,11 +18,11 @@ export { export { getRequestStartLineNumber, getRequestEndLineNumber, - stringifyRequest, replaceRequestVariables, getCurlRequest, trackSentRequests, getAutoIndentedRequests, + getRequestFromEditor, } from './requests_utils'; export { getDocumentationLinkFromAutocomplete, diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts index 8704ae9e94eec..f028bfde48086 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.test.ts @@ -6,14 +6,16 @@ * Side Public License, v 1. */ +import { monaco, ParsedRequest } from '@kbn/monaco'; +import type { MetricsTracker } from '../../../../../types'; import { getAutoIndentedRequests, getCurlRequest, + getRequestEndLineNumber, replaceRequestVariables, - stringifyRequest, trackSentRequests, + getRequestFromEditor, } from './requests_utils'; -import { MetricsTracker } from '../../../../../types'; describe('requests_utils', () => { const dataObjects = [ @@ -26,35 +28,23 @@ describe('requests_utils', () => { test: 'test', }, ]; - describe('stringifyRequest', () => { - const request = { - startOffset: 0, - endOffset: 11, - method: 'get', - url: '_search some_text', - }; - it('calls the "removeTrailingWhitespaces" on the url', () => { - const stringifiedRequest = stringifyRequest(request); - expect(stringifiedRequest.url).toBe('_search'); - }); - - it('normalizes the method to upper case', () => { - const stringifiedRequest = stringifyRequest(request); - expect(stringifiedRequest.method).toBe('GET'); - }); - it('stringifies the request body', () => { - const result = stringifyRequest({ ...request, data: [dataObjects[0]] }); - expect(result.data.length).toBe(1); - expect(result.data[0]).toBe(JSON.stringify(dataObjects[0], null, 2)); - }); - - it('works for several request bodies', () => { - const result = stringifyRequest({ ...request, data: dataObjects }); - expect(result.data.length).toBe(2); - expect(result.data[0]).toBe(JSON.stringify(dataObjects[0], null, 2)); - expect(result.data[1]).toBe(JSON.stringify(dataObjects[1], null, 2)); - }); - }); + const inlineData = '{"query":"test"}'; + const multiLineData = '{\n "query": "test"\n}'; + const invalidData = '{\n "query":\n {'; + const getMockModel = (content: string[]) => { + return { + getLineContent: (lineNumber: number) => content[lineNumber - 1], + getValueInRange: ({ + startLineNumber, + endLineNumber, + }: { + startLineNumber: number; + endLineNumber: number; + }) => content.slice(startLineNumber - 1, endLineNumber).join('\n'), + getLineMaxColumn: (lineNumber: number) => content[lineNumber - 1].length, + getLineCount: () => content.length, + } as unknown as monaco.editor.ITextModel; + }; describe('replaceRequestVariables', () => { const variables = [ @@ -213,9 +203,6 @@ describe('requests_utils', () => { ]; const TEST_REQUEST_1 = { - method: 'GET', - url: '_search', - data: [{ query: { match_all: {} } }], // Offsets are with respect to the sample editor text startLineNumber: 2, endLineNumber: 7, @@ -224,9 +211,6 @@ describe('requests_utils', () => { }; const TEST_REQUEST_2 = { - method: 'GET', - url: '_all', - data: [], // Offsets are with respect to the sample editor text startLineNumber: 10, endLineNumber: 10, @@ -235,10 +219,6 @@ describe('requests_utils', () => { }; const TEST_REQUEST_3 = { - method: 'POST', - url: '/_bulk', - // Multi-data - data: [{ index: { _index: 'books' } }, { name: '1984' }, { name: 'Atomic habits' }], // Offsets are with respect to the sample editor text startLineNumber: 15, endLineNumber: 23, @@ -247,11 +227,8 @@ describe('requests_utils', () => { }; const TEST_REQUEST_4 = { - method: 'GET', - url: '_search', - data: [{ query: { match_all: {} } }], // Offsets are with respect to the sample editor text - startLineNumber: 24, + startLineNumber: 25, endLineNumber: 30, startOffset: 1, endOffset: 36, @@ -353,17 +330,131 @@ describe('requests_utils', () => { expect(formattedData).toBe(expectedResultLines.join('\n')); }); - it('does not auto-indent a request with comments', () => { - const requestText = sampleEditorTextLines - .slice(TEST_REQUEST_4.startLineNumber - 1, TEST_REQUEST_4.endLineNumber) + it(`auto-indents method line but doesn't auto-indent data with comments`, () => { + const methodLine = sampleEditorTextLines[TEST_REQUEST_4.startLineNumber - 1]; + const dataText = sampleEditorTextLines + .slice(TEST_REQUEST_4.startLineNumber, TEST_REQUEST_4.endLineNumber) .join('\n'); const formattedData = getAutoIndentedRequests( [TEST_REQUEST_4], - requestText, + `${methodLine}\n${dataText}`, sampleEditorTextLines.join('\n') ); - expect(formattedData).toBe(requestText); + expect(formattedData).toBe(`GET _search // test comment\n${dataText}`); + }); + }); + + describe('getRequestEndLineNumber', () => { + const parsedRequest: ParsedRequest = { + startOffset: 1, + }; + it('detects the end of the request when there is a line that starts with a method (next not parsed request)', () => { + /* + * Mocking the model to return these 6 lines of text + * 1. GET /_search + * 2. { + * 3. empty + * 4. empty + * 5. POST _search + * 6. empty + */ + const content = ['GET /_search', '{', '', '', 'POST _search', '']; + const model = { + ...getMockModel(content), + getPositionAt: () => ({ lineNumber: 1 }), + } as unknown as monaco.editor.ITextModel; + + const result = getRequestEndLineNumber({ + parsedRequest, + model, + startLineNumber: 1, + }); + expect(result).toEqual(2); + }); + + it('detects the end of the request when the text ends', () => { + /* + * Mocking the model to return these 4 lines of text + * 1. GET /_search + * 2. { + * 3. { + * 4. empty + */ + const content = ['GET _search', '{', ' {', '']; + const model = { + ...getMockModel(content), + getPositionAt: () => ({ lineNumber: 1 }), + } as unknown as monaco.editor.ITextModel; + + const result = getRequestEndLineNumber({ + parsedRequest, + model, + startLineNumber: 1, + }); + expect(result).toEqual(3); + }); + }); + + describe('getRequestFromEditor', () => { + it('cleans up any text following the url', () => { + const content = ['GET _search // inline comment']; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 1); + expect(request).toEqual({ method: 'GET', url: '_search', data: [] }); + }); + + it(`doesn't incorrectly removes parts of url params that include whitespaces`, () => { + const content = ['GET _search?query="test test"']; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 1); + expect(request).toEqual({ method: 'GET', url: '_search?query="test test"', data: [] }); + }); + + it(`normalizes method to upper case`, () => { + const content = ['get _search']; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 1); + expect(request).toEqual({ method: 'GET', url: '_search', data: [] }); + }); + + it('correctly includes the request body', () => { + const content = ['GET _search', '{', ' "query": {}', '}']; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 4); + expect(request).toEqual({ method: 'GET', url: '_search', data: ['{\n "query": {}\n}'] }); + }); + + it('works for several request bodies', () => { + const content = ['GET _search', '{', ' "query": {}', '}', '{', ' "query": {}', '}']; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 7); + expect(request).toEqual({ + method: 'GET', + url: '_search', + data: ['{\n "query": {}\n}', '{\n "query": {}\n}'], + }); + }); + + it('splits several json objects', () => { + const content = ['GET _search', inlineData, ...multiLineData.split('\n'), inlineData]; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 6); + expect(request).toEqual({ + method: 'GET', + url: '_search', + data: [inlineData, multiLineData, inlineData], + }); + }); + it('works for invalid json objects', () => { + const content = ['GET _search', inlineData, ...invalidData.split('\n')]; + const model = getMockModel(content); + const request = getRequestFromEditor(model, 1, 5); + expect(request).toEqual({ + method: 'GET', + url: '_search', + data: [inlineData, invalidData], + }); }); }); }); diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts index 8f791751bba67..b6f5921e920a4 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/requests_utils.ts @@ -7,26 +7,17 @@ */ import { monaco, ParsedRequest } from '@kbn/monaco'; +import { parse } from 'hjson'; import { constructUrl } from '../../../../../lib/es'; -import { MetricsTracker } from '../../../../../types'; +import type { MetricsTracker } from '../../../../../types'; import type { DevToolsVariable } from '../../../../components'; -import type { EditorRequest } from '../types'; -import { urlVariableTemplateRegex, dataVariableTemplateRegex } from './constants'; -import { removeTrailingWhitespaces } from './tokens_utils'; -import { AdjustedParsedRequest } from '../types'; - -/* - * This function stringifies and normalizes the parsed request: - * - the method is converted to upper case - * - any trailing comments are removed from the url - * - the request body is stringified from an object using JSON.stringify - */ -export const stringifyRequest = (parsedRequest: ParsedRequest): EditorRequest => { - const url = parsedRequest.url ? removeTrailingWhitespaces(parsedRequest.url) : ''; - const method = parsedRequest.method?.toUpperCase() ?? ''; - const data = parsedRequest.data?.map((parsedData) => JSON.stringify(parsedData, null, 2)); - return { url, method, data: data ?? [] }; -}; +import type { EditorRequest, AdjustedParsedRequest } from '../types'; +import { + urlVariableTemplateRegex, + dataVariableTemplateRegex, + startsWithMethodRegex, +} from './constants'; +import { parseLine } from './tokens_utils'; /* * This function replaces any variables with its values stored in localStorage. @@ -52,9 +43,13 @@ export const getCurlRequest = ( ): string => { const curlUrl = constructUrl(elasticsearchBaseUrl, url); let curlRequest = `curl -X${method} "${curlUrl}" -H "kbn-xsrf: reporting"`; - if (data.length > 0) { + if (data && data.length) { + const joinedData = data.join('\n'); + curlRequest += ` -H "Content-Type: application/json" -d'\n`; - curlRequest += data.join('\n'); + + // We escape single quoted strings that are wrapped in single quoted strings + curlRequest += joinedData.replace(/'/g, "'\\''"); curlRequest += "'"; } return curlRequest; @@ -88,25 +83,42 @@ export const getRequestStartLineNumber = ( * If there is no end offset (the parser was not able to parse this request completely), * then the last non-empty line is returned or the line before the next request. */ -export const getRequestEndLineNumber = ( - parsedRequest: ParsedRequest, - model: monaco.editor.ITextModel, - index: number, - parsedRequests: ParsedRequest[] -): number => { +export const getRequestEndLineNumber = ({ + parsedRequest, + nextRequest, + model, + startLineNumber, +}: { + parsedRequest: ParsedRequest; + nextRequest?: ParsedRequest; + model: monaco.editor.ITextModel; + startLineNumber: number; +}): number => { let endLineNumber: number; if (parsedRequest.endOffset) { // if the parser set an end offset for this request, then find the line number for it endLineNumber = model.getPositionAt(parsedRequest.endOffset).lineNumber; } else { // if no end offset, try to find the line before the next request starts - const nextRequest = parsedRequests.at(index + 1); if (nextRequest) { const nextRequestStartLine = model.getPositionAt(nextRequest.startOffset).lineNumber; - endLineNumber = nextRequestStartLine - 1; + endLineNumber = + nextRequestStartLine > startLineNumber ? nextRequestStartLine - 1 : startLineNumber; } else { - // if there is no next request, take the last line of the model - endLineNumber = model.getLineCount(); + // if there is no next request, find the end of the text or the line that starts with a method + let nextLineNumber = model.getPositionAt(parsedRequest.startOffset).lineNumber + 1; + let nextLineContent: string; + while (nextLineNumber <= model.getLineCount()) { + nextLineContent = model.getLineContent(nextLineNumber).trim(); + if (nextLineContent.match(startsWithMethodRegex)) { + // found a line that starts with a method, stop iterating + break; + } + nextLineNumber++; + } + // nextLineNumber is now either the line with a method or 1 line after the end of the text + // set the end line for this request to the line before nextLineNumber + endLineNumber = nextLineNumber > startLineNumber ? nextLineNumber - 1 : startLineNumber; } } // if the end line is empty, go up to find the first non-empty line @@ -118,44 +130,6 @@ export const getRequestEndLineNumber = ( return endLineNumber; }; -const isJsonString = (str: string) => { - try { - JSON.parse(str); - } catch (e) { - return false; - } - return true; -}; - -/* - * Internal helpers - */ -const replaceVariables = ( - text: string, - variables: DevToolsVariable[], - isDataVariable: boolean -): string => { - const variableRegex = isDataVariable ? dataVariableTemplateRegex : urlVariableTemplateRegex; - if (variableRegex.test(text)) { - text = text.replaceAll(variableRegex, (match, key) => { - const variable = variables.find(({ name }) => name === key); - const value = variable?.value; - - if (isDataVariable && value) { - // If the variable value is an object, add it as it is. Otherwise, surround it with quotes. - return isJsonString(value) ? value : `"${value}"`; - } - - return value ?? match; - }); - } - return text; -}; - -const containsComments = (text: string) => { - return text.indexOf('//') >= 0 || text.indexOf('/*') >= 0; -}; - /** * This function takes a string containing unformatted Console requests and * returns a text in which the requests are auto-indented. @@ -184,19 +158,19 @@ export const getAutoIndentedRequests = ( ) { // Start of a request const requestLines = allTextLines.slice(request.startLineNumber - 1, request.endLineNumber); - - if (requestLines.some((line) => containsComments(line))) { - // If request has comments, add it as it is - without formatting - // TODO: Format requests with comments - formattedTextLines.push(...requestLines); + const firstLine = cleanUpWhitespaces(requestLines[0]); + formattedTextLines.push(firstLine); + const dataLines = requestLines.slice(1); + if (dataLines.some((line) => containsComments(line))) { + // If data has comments, add it as it is - without formatting + // TODO: Format requests with comments https://github.com/elastic/kibana/issues/182138 + formattedTextLines.push(...dataLines); } else { - // If no comments, add stringified parsed request - const stringifiedRequest = stringifyRequest(request); - const firstLine = stringifiedRequest.method + ' ' + stringifiedRequest.url; - formattedTextLines.push(firstLine); - - if (stringifiedRequest.data && stringifiedRequest.data.length > 0) { - formattedTextLines.push(...stringifiedRequest.data); + // If no comments, indent data + if (requestLines.length > 1) { + const dataString = dataLines.join('\n'); + const dataJsons = splitDataIntoJsonObjects(dataString); + formattedTextLines.push(...dataJsons.map(indentData)); } } @@ -205,10 +179,116 @@ export const getAutoIndentedRequests = ( } else { // Current line is a comment or whitespaces // Trim white spaces and add it to the formatted text - formattedTextLines.push(selectedTextLines[currentLineIndex].trim()); + formattedTextLines.push(cleanUpWhitespaces(selectedTextLines[currentLineIndex])); currentLineIndex++; } } return formattedTextLines.join('\n'); }; + +/* + * This function extracts a normalized method and url from the editor and + * the "raw" text of the request body without any changes to it. The only normalization + * for request body is to split several json objects into an array of strings. + */ +export const getRequestFromEditor = ( + model: monaco.editor.ITextModel, + startLineNumber: number, + endLineNumber: number +): EditorRequest | null => { + const methodUrlLine = model.getLineContent(startLineNumber).trim(); + if (!methodUrlLine) { + return null; + } + const { method, url } = parseLine(methodUrlLine, false); + if (!method || !url) { + return null; + } + const upperCaseMethod = method.toUpperCase(); + + if (endLineNumber <= startLineNumber) { + return { method: upperCaseMethod, url, data: [] }; + } + const dataString = model + .getValueInRange({ + startLineNumber: startLineNumber + 1, + startColumn: 1, + endLineNumber, + endColumn: model.getLineMaxColumn(endLineNumber), + }) + .trim(); + const data = splitDataIntoJsonObjects(dataString); + + return { method: upperCaseMethod, url, data }; +}; + +export const containsComments = (text: string) => { + return text.indexOf('//') >= 0 || text.indexOf('/*') >= 0; +}; + +export const indentData = (dataString: string): string => { + try { + const parsedData = parse(dataString); + + return JSON.stringify(parsedData, null, 2); + } catch (e) { + return dataString; + } +}; + +// ---------------------------------- Internal helpers ---------------------------------- + +const isJsonString = (str: string) => { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; +}; + +const replaceVariables = ( + text: string, + variables: DevToolsVariable[], + isDataVariable: boolean +): string => { + const variableRegex = isDataVariable ? dataVariableTemplateRegex : urlVariableTemplateRegex; + if (variableRegex.test(text)) { + text = text.replaceAll(variableRegex, (match, key) => { + const variable = variables.find(({ name }) => name === key); + const value = variable?.value; + + if (isDataVariable && value) { + // If the variable value is an object, add it as it is. Otherwise, surround it with quotes. + return isJsonString(value) ? value : `"${value}"`; + } + + return value ?? match; + }); + } + return text; +}; + +const splitDataIntoJsonObjects = (dataString: string): string[] => { + const jsonSplitRegex = /}\s*{/; + if (dataString.match(jsonSplitRegex)) { + return dataString.split(jsonSplitRegex).map((part, index, parts) => { + let restoredBracketsString = part; + // add an opening bracket to all parts except the 1st + if (index > 0) { + restoredBracketsString = `{${restoredBracketsString}`; + } + // add a closing bracket to all parts except the last + if (index < parts.length - 1) { + restoredBracketsString = `${restoredBracketsString}}`; + } + return restoredBracketsString; + }); + } + return [dataString]; +}; + +const cleanUpWhitespaces = (line: string): string => { + return line.trim().replaceAll(/\s+/g, ' '); +}; diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts index 702b9a589e662..4e7a383ceebf3 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.test.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { parseBody, removeTrailingWhitespaces, parseUrl } from './tokens_utils'; +import { parseBody, removeTrailingWhitespaces, parseUrl, parseLine } from './tokens_utils'; describe('tokens_utils', () => { describe('removeTrailingWhitespaces', () => { @@ -32,6 +32,53 @@ describe('tokens_utils', () => { }); }); + describe('parseLine', () => { + it('works with a comment', () => { + const { method, url } = parseLine('GET _search // a comment'); + expect(method).toBe('GET'); + expect(url).toBe('_search'); + }); + it('works with a url param', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine( + 'GET _search?query="test1 test2 test3" // comment' + ); + expect(method).toBe('GET'); + expect(url).toBe('_search?query="test1 test2 test3"'); + expect(urlPathTokens).toEqual(['_search']); + expect(urlParamsTokens[0]).toEqual(['query', '"test1 test2 test3"']); + }); + it('works with multiple whitespaces', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine( + ' GET _search?query="test1 test2 test3" // comment' + ); + expect(method).toBe('GET'); + expect(url).toBe('_search?query="test1 test2 test3"'); + expect(urlPathTokens).toEqual(['_search']); + expect(urlParamsTokens[0]).toEqual(['query', '"test1 test2 test3"']); + }); + it('normalizes the method to upper case', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine('Get _'); + expect(method).toBe('GET'); + expect(url).toBe('_'); + expect(urlPathTokens).toEqual(['_']); + expect(urlParamsTokens).toEqual([]); + }); + it('correctly parses the line when the url is empty, no whitespace', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine('GET'); + expect(method).toBe('GET'); + expect(url).toBe(''); + expect(urlPathTokens).toEqual([]); + expect(urlParamsTokens).toEqual([]); + }); + it('correctly parses the line when the url is empty, with whitespace', () => { + const { method, url, urlPathTokens, urlParamsTokens } = parseLine('GET '); + expect(method).toBe('GET'); + expect(url).toBe(''); + expect(urlPathTokens).toEqual([]); + expect(urlParamsTokens).toEqual([]); + }); + }); + describe('parseBody', () => { const testCases: Array<{ value: string; tokens: string[] }> = [ { diff --git a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts index 2615bd2c45d74..8e9c723c8f7a2 100644 --- a/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts +++ b/src/plugins/console/public/application/containers/editor/monaco/utils/tokens_utils.ts @@ -19,18 +19,25 @@ import { /* * This function parses a line with the method and url. * The url is parsed into path and params, each parsed into tokens. - * Returns method, urlPathTokens and urlParamsTokens which are arrays of strings. + * Returns method, url, urlPathTokens and urlParamsTokens which are arrays of strings. */ -export const parseLine = (line: string): ParsedLineTokens => { - // try to parse into method and url (split on whitespace) - const parts = line.split(whitespacesRegex); +export const parseLine = (line: string, parseUrlIntoTokens: boolean = true): ParsedLineTokens => { + line = line.trim(); + const firstWhitespaceIndex = line.indexOf(' '); + if (firstWhitespaceIndex < 0) { + // there is no url, only method + return { method: line, url: '', urlPathTokens: [], urlParamsTokens: [] }; + } // 1st part is the method - const method = parts[0].toUpperCase(); + const method = line.slice(0, firstWhitespaceIndex).trim().toUpperCase(); // 2nd part is the url - const url = parts[1]; - // try to parse into url path and url params (split on question mark) - const { urlPathTokens, urlParamsTokens } = parseUrl(url); - return { method, urlPathTokens, urlParamsTokens }; + const url = removeTrailingWhitespaces(line.slice(firstWhitespaceIndex).trim()); + if (parseUrlIntoTokens) { + // try to parse into url path and url params (split on question mark) + const { urlPathTokens, urlParamsTokens } = parseUrl(url); + return { method, url, urlPathTokens, urlParamsTokens }; + } + return { method, url, urlPathTokens: [], urlParamsTokens: [] }; }; /* @@ -444,6 +451,7 @@ export const containsUrlParams = (lineContent: string): boolean => { */ interface ParsedLineTokens { method: string; + url: string; urlPathTokens: string[]; urlParamsTokens: string[][]; } diff --git a/test/functional/apps/console/monaco/_console.ts b/test/functional/apps/console/monaco/_console.ts index 1c6afc39c5046..b48ba75529579 100644 --- a/test/functional/apps/console/monaco/_console.ts +++ b/test/functional/apps/console/monaco/_console.ts @@ -14,7 +14,6 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const log = getService('log'); - const toasts = getService('toasts'); const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'console', 'header']); const security = getService('security'); @@ -58,12 +57,17 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { expect(initialSize.width).to.be.greaterThan(afterSize.width); }); - it('should not send request with unsupported HTTP verbs', async () => { + it('should return statusCode 400 to unsupported HTTP verbs', async () => { + const expectedResponseContains = '"statusCode": 400'; await PageObjects.console.monaco.clearEditorText(); await PageObjects.console.monaco.enterText('OPTIONS /'); await PageObjects.console.clickPlay(); await retry.try(async () => { - expect(await toasts.getCount()).to.equal(1); + const actualResponse = await PageObjects.console.monaco.getOutputText(); + log.debug(actualResponse); + expect(actualResponse).to.contain(expectedResponseContains); + + expect(await PageObjects.console.hasSuccessBadge()).to.be(false); }); }); diff --git a/test/functional/apps/console/monaco/_context_menu.ts b/test/functional/apps/console/monaco/_context_menu.ts index 0fbcb123937db..1e95e74a851b1 100644 --- a/test/functional/apps/console/monaco/_context_menu.ts +++ b/test/functional/apps/console/monaco/_context_menu.ts @@ -133,15 +133,27 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.switchTab(0); }); - // not implemented yet for monaco https://github.com/elastic/kibana/issues/185891 - it.skip('should toggle auto indent when auto indent button is clicked', async () => { - await PageObjects.console.clearTextArea(); - await PageObjects.console.enterRequest('GET _search\n{"query": {"match_all": {}}}'); + it('should auto indent when auto indent button is clicked', async () => { + await PageObjects.console.monaco.clearEditorText(); + await PageObjects.console.monaco.enterText('GET _search\n{"query": {"match_all": {}}}'); + await PageObjects.console.clickContextMenu(); + await PageObjects.console.clickAutoIndentButton(); + // Retry until the request is auto indented + await retry.try(async () => { + const request = await PageObjects.console.monaco.getEditorText(); + expect(request).to.be.eql('GET _search\n{\n "query": {\n "match_all": {}\n }\n}'); + }); + }); + + // not implemented for monaco yet https://github.com/elastic/kibana/issues/185891 + it.skip('should collapse the request when auto indent button is clicked again', async () => { + await PageObjects.console.monaco.clearEditorText(); + await PageObjects.console.monaco.enterText('GET _search\n{"query": {"match_all": {}}}'); await PageObjects.console.clickContextMenu(); await PageObjects.console.clickAutoIndentButton(); // Retry until the request is auto indented await retry.try(async () => { - const request = await PageObjects.console.getRequest(); + const request = await PageObjects.console.monaco.getEditorText(); expect(request).to.be.eql('GET _search\n{\n "query": {\n "match_all": {}\n }\n}'); }); @@ -150,7 +162,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.console.clickAutoIndentButton(); // Retry until the request is condensed await retry.try(async () => { - const request = await PageObjects.console.getRequest(); + const request = await PageObjects.console.monaco.getEditorText(); expect(request).to.be.eql('GET _search\n{"query":{"match_all":{}}}'); }); }); diff --git a/test/functional/apps/console/monaco/_misc_console_behavior.ts b/test/functional/apps/console/monaco/_misc_console_behavior.ts index f7ad2957c8411..f9b93872740ad 100644 --- a/test/functional/apps/console/monaco/_misc_console_behavior.ts +++ b/test/functional/apps/console/monaco/_misc_console_behavior.ts @@ -147,5 +147,34 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); }); + + describe('invalid requests', () => { + const invalidRequestText = 'GET _search\n{"query": {"match_all": {'; + it(`should not delete any text if indentations applied to an invalid request`, async () => { + await PageObjects.console.monaco.clearEditorText(); + await PageObjects.console.monaco.enterText(invalidRequestText); + await PageObjects.console.monaco.selectCurrentRequest(); + await PageObjects.console.monaco.pressCtrlI(); + // Sleep for a bit and then check that the text has not changed + await PageObjects.common.sleep(1000); + await retry.try(async () => { + const request = await PageObjects.console.monaco.getEditorText(); + expect(request).to.be.eql(invalidRequestText); + }); + }); + + it(`should include an invalid json when sending a request`, async () => { + await PageObjects.console.monaco.clearEditorText(); + await PageObjects.console.monaco.enterText(invalidRequestText); + await PageObjects.console.monaco.selectCurrentRequest(); + await PageObjects.console.monaco.pressCtrlEnter(); + + await retry.try(async () => { + const actualResponse = await PageObjects.console.monaco.getOutputText(); + expect(actualResponse).to.contain('parsing_exception'); + expect(await PageObjects.console.hasSuccessBadge()).to.be(false); + }); + }); + }); }); }