[Console] Fix the end range of selected requests (#189747)

## Summary

Fixes https://github.com/elastic/kibana/issues/189366
Fixes https://github.com/elastic/kibana/issues/186773

This PR refactors how the request body is being extracted from the
editor to use for "sendRequest" and "copyAsCurl" functions. Previously
the editor actions provider would rely on the parser to get a JSON
object or several for request body. The downside of this implementation
was when the parser would not be able to fully process the json object.
That could lead to potential text loss and the editor would process the
requests in a way that was not always obvious to the user. For example,
the editor would highlight the request with the json object, but when
sending it to ES the request body would be completely ignored.
Instead this PR suggests to use the "raw" text from the editor for
actions and give the user more transparency and control over the
requests. We also don't need to keep the information about requests in
the parser, which might affect browser performance for longer texts.

### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces—unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes—Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Yulia Čech 2024-08-20 14:16:00 +02:00 committed by GitHub
parent 1053ec6c7b
commit f3a6527437
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 474 additions and 209 deletions

View file

@ -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();

View file

@ -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);
});
});

View file

@ -14,9 +14,6 @@ export interface ErrorAnnotation {
export interface ParsedRequest {
startOffset: number;
endOffset?: number;
method: string;
url?: string;
data?: Array<Record<string, unknown>>;
}
export interface ConsoleParserResult {
errors: ErrorAnnotation[];

View file

@ -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<string> {
@ -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;
}

View file

@ -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

View file

@ -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,

View file

@ -18,11 +18,11 @@ export {
export {
getRequestStartLineNumber,
getRequestEndLineNumber,
stringifyRequest,
replaceRequestVariables,
getCurlRequest,
trackSentRequests,
getAutoIndentedRequests,
getRequestFromEditor,
} from './requests_utils';
export {
getDocumentationLinkFromAutocomplete,

View file

@ -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],
});
});
});
});

View file

@ -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, ' ');
};

View file

@ -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[] }> = [
{

View file

@ -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[][];
}

View file

@ -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);
});
});

View file

@ -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.getRequest();
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.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":{}}}');
});
});

View file

@ -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);
});
});
});
});
}