mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-25 13:45:52 +00:00
feat: add redirect and timeout in request settings (#5672)
* feat: add redirect and timeout in request settings
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { IconChevronDown, IconX } from '@tabler/icons';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
import Dropdown from 'components/Dropdown';
|
||||
|
||||
const InheritableSettingsInput = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
description,
|
||||
onKeyDown,
|
||||
isInherited,
|
||||
onDropdownSelect,
|
||||
onValueChange,
|
||||
onCustomValueReset
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{isInherited ? (
|
||||
<Dropdown
|
||||
icon={(
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 text-xs rounded-sm outline-none transition-colors duration-100 w-24 h-8 flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: theme.modal.input.bg,
|
||||
border: `1px solid ${theme.modal.input.border}`,
|
||||
color: theme.modal.input.text
|
||||
}}
|
||||
>
|
||||
<span>Inherit</span>
|
||||
<IconChevronDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="dropdown-item" onClick={() => onDropdownSelect('inherit')}>
|
||||
Inherit
|
||||
</div>
|
||||
<div className="dropdown-item" onClick={() => onDropdownSelect('custom')}>
|
||||
Custom
|
||||
</div>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className="block px-2 py-1 pr-6 rounded-sm outline-none transition-colors duration-100 w-24 h-8"
|
||||
style={{
|
||||
backgroundColor: theme.modal.input.bg,
|
||||
border: `1px solid ${theme.modal.input.border}`,
|
||||
color: theme.modal.input.text
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={value}
|
||||
onChange={onValueChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCustomValueReset}
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors"
|
||||
title="Reset to inherit"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InheritableSettingsInput;
|
||||
@@ -6,7 +6,8 @@ const ToggleSelector = ({
|
||||
label,
|
||||
description,
|
||||
disabled = false,
|
||||
size = 'small' // 'small', 'medium', 'large'
|
||||
size = 'small', // 'small', 'medium', 'large'
|
||||
'data-testid': dataTestId
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
small: {
|
||||
@@ -29,13 +30,24 @@ const ToggleSelector = ({
|
||||
const currentSize = sizeClasses[size];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
className={`
|
||||
relative inline-flex ${currentSize.container} mx-1 items-center rounded-full transition-colors
|
||||
relative inline-flex ${currentSize.container} flex-shrink-0 items-center rounded-full transition-colors
|
||||
focus:outline-none focus:ring-1 focus:ring-offset-1
|
||||
${disabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
@@ -57,16 +69,6 @@ const ToggleSelector = ({
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100">
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,47 +1,156 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import get from 'lodash/get';
|
||||
import { IconTag } from '@tabler/icons';
|
||||
import ToggleSelector from 'components/RequestPane/Settings/ToggleSelector';
|
||||
import SettingsInput from 'components/SettingsInput';
|
||||
import InheritableSettingsInput from 'components/InheritableSettingsInput';
|
||||
import { updateItemSettings } from 'providers/ReduxStore/slices/collections';
|
||||
import { saveRequest, sendRequest } from 'providers/ReduxStore/slices/collections/actions';
|
||||
import Tags from './Tags/index';
|
||||
|
||||
// Default settings configuration
|
||||
const DEFAULT_SETTINGS = {
|
||||
encodeUrl: false,
|
||||
followRedirects: true,
|
||||
maxRedirects: 5,
|
||||
timeout: 'inherit'
|
||||
};
|
||||
|
||||
const Settings = ({ item, collection }) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// get the length of active params, headers, asserts and vars as well as the contents of the body, tests and script
|
||||
// Get current settings with defaults applied
|
||||
const getPropertyFromDraftOrRequest = (propertyKey) =>
|
||||
item.draft ? get(item, `draft.${propertyKey}`, {}) : get(item, propertyKey, {});
|
||||
|
||||
const { encodeUrl } = getPropertyFromDraftOrRequest('settings');
|
||||
const rawSettings = getPropertyFromDraftOrRequest('settings');
|
||||
const settings = { ...DEFAULT_SETTINGS, ...rawSettings };
|
||||
const { encodeUrl, followRedirects, maxRedirects, timeout } = settings;
|
||||
|
||||
const onToggleUrlEncoding = useCallback(() => {
|
||||
// Reusable function to update settings
|
||||
const updateSetting = useCallback((settingUpdate) => {
|
||||
const updatedSettings = { ...settings, ...settingUpdate };
|
||||
dispatch(updateItemSettings({
|
||||
collectionUid: collection.uid,
|
||||
itemUid: item.uid,
|
||||
settings: { encodeUrl: !encodeUrl }
|
||||
settings: updatedSettings
|
||||
}));
|
||||
}, [encodeUrl, dispatch, collection.uid, item.uid]);
|
||||
}, [dispatch, collection.uid, item.uid, settings]);
|
||||
|
||||
// Setting change handlers
|
||||
const onToggleUrlEncoding = useCallback(() =>
|
||||
updateSetting({ encodeUrl: !encodeUrl }), [encodeUrl, updateSetting]);
|
||||
|
||||
const onToggleFollowRedirects = useCallback(() =>
|
||||
updateSetting({ followRedirects: !followRedirects }), [followRedirects, updateSetting]);
|
||||
|
||||
const onMaxRedirectsChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
// Only allow empty string or digits
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
const numericValue = value === '' ? 0 : parseInt(value, 10);
|
||||
updateSetting({ maxRedirects: numericValue });
|
||||
}
|
||||
}, [updateSetting]);
|
||||
|
||||
const onTimeoutChange = useCallback((e) => {
|
||||
const value = e.target.value;
|
||||
// Only allow empty string or digits
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
const numericValue = value === '' ? 0 : parseInt(value, 10);
|
||||
updateSetting({ timeout: numericValue });
|
||||
}
|
||||
}, [updateSetting]);
|
||||
|
||||
// Check if timeout is inherited
|
||||
const isTimeoutInherited = timeout === 'inherit' || timeout === undefined || timeout === null;
|
||||
|
||||
const handleTimeoutDropdownSelect = useCallback((option) => {
|
||||
if (option === 'inherit') {
|
||||
onTimeoutChange({ target: { value: 'inherit' } });
|
||||
} else if (option === 'custom') {
|
||||
// Switch to custom value - start with 0
|
||||
onTimeoutChange({ target: { value: 0 } });
|
||||
}
|
||||
}, [onTimeoutChange]);
|
||||
|
||||
// Keyboard shortcut handlers
|
||||
const onSave = useCallback(() => {
|
||||
dispatch(saveRequest(item.uid, collection.uid));
|
||||
}, [dispatch, item.uid, collection.uid]);
|
||||
|
||||
const onRun = useCallback(() => {
|
||||
dispatch(sendRequest(item, collection.uid));
|
||||
}, [dispatch, item, collection.uid]);
|
||||
|
||||
// Keyboard shortcut handler for input fields
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onRun();
|
||||
}
|
||||
}, [onSave, onRun]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-10">
|
||||
<div className='flex flex-col gap-2 max-w-[400px]'>
|
||||
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1">
|
||||
<IconTag size={16} />
|
||||
Tags
|
||||
</h3>
|
||||
<div label="Tags">
|
||||
<div className="h-full w-full">
|
||||
<div className="text-xs mb-4 text-muted">Configure request settings for this item.</div>
|
||||
<div className="bruno-form">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xs font-medium text-gray-900 dark:text-gray-100 flex items-center gap-1 mb-4">
|
||||
<IconTag size={16} />
|
||||
Tags
|
||||
</h3>
|
||||
<Tags item={item} collection={collection} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<ToggleSelector
|
||||
checked={encodeUrl}
|
||||
onChange={onToggleUrlEncoding}
|
||||
label="URL Encoding"
|
||||
description="Automatically encode query parameters in the URL"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleSelector
|
||||
checked={encodeUrl}
|
||||
onChange={onToggleUrlEncoding}
|
||||
label="URL Encoding"
|
||||
description="Automatically encode query parameters in the URL"
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleSelector
|
||||
checked={followRedirects}
|
||||
onChange={onToggleFollowRedirects}
|
||||
label="Automatically Follow Redirects"
|
||||
description="Follow HTTP redirects automatically"
|
||||
size="medium"
|
||||
data-testid="follow-redirects-toggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsInput
|
||||
id="maxRedirects"
|
||||
label="Max Redirects"
|
||||
value={maxRedirects}
|
||||
onChange={onMaxRedirectsChange}
|
||||
description="Set a limit for the number of redirects to follow"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
<InheritableSettingsInput
|
||||
id="timeout"
|
||||
label="Timeout (ms)"
|
||||
value={timeout}
|
||||
description="Set maximum time to wait before aborting the request"
|
||||
onKeyDown={handleKeyDown}
|
||||
isInherited={isTimeoutInherited}
|
||||
onDropdownSelect={handleTimeoutDropdownSelect}
|
||||
onValueChange={(e) => !isTimeoutInherited && onTimeoutChange(e)}
|
||||
onCustomValueReset={() => onTimeoutChange({ target: { value: 'inherit' } })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
47
packages/bruno-app/src/components/SettingsInput/index.js
Normal file
47
packages/bruno-app/src/components/SettingsInput/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import { useTheme } from 'providers/Theme';
|
||||
|
||||
const SettingsInput = ({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
className = '',
|
||||
description = '',
|
||||
onKeyDown
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col">
|
||||
<label className="text-xs font-medium text-gray-900 dark:text-gray-100" htmlFor={id}>
|
||||
{label}
|
||||
</label>
|
||||
{description && (
|
||||
<p className="text-xs text-gray-700 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
className={`block px-2 py-1 rounded-sm outline-none transition-colors duration-100 w-24 h-8 ${className}`}
|
||||
style={{
|
||||
backgroundColor: theme.modal.input.bg,
|
||||
border: `1px solid ${theme.modal.input.border}`
|
||||
}}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsInput;
|
||||
@@ -617,6 +617,9 @@ export const collectionsSlice = createSlice({
|
||||
|
||||
if (item && item.draft) {
|
||||
item.request = item.draft.request;
|
||||
if (item.draft.settings) {
|
||||
item.settings = item.draft.settings;
|
||||
}
|
||||
item.draft = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,14 +346,24 @@ const runSingleRequest = async function (
|
||||
}
|
||||
}
|
||||
|
||||
let requestMaxRedirects = request.maxRedirects
|
||||
request.maxRedirects = 0
|
||||
|
||||
// Set default value for requestMaxRedirects if not explicitly set
|
||||
if (requestMaxRedirects === undefined) {
|
||||
// Get followRedirects setting, default to true for backward compatibility
|
||||
const followRedirects = request.settings?.followRedirects ?? true;
|
||||
|
||||
// Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5
|
||||
let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5;
|
||||
|
||||
// Ensure it's a valid number
|
||||
if (typeof requestMaxRedirects !== 'number' || requestMaxRedirects < 0) {
|
||||
requestMaxRedirects = 5; // Default to 5 redirects
|
||||
}
|
||||
|
||||
// If followRedirects is disabled, set maxRedirects to 0 to disable all redirects
|
||||
if (!followRedirects) {
|
||||
requestMaxRedirects = 0;
|
||||
}
|
||||
|
||||
request.maxRedirects = 0;
|
||||
|
||||
// Handle OAuth2 authentication
|
||||
if (request.oauth2) {
|
||||
try {
|
||||
@@ -384,12 +394,22 @@ const runSingleRequest = async function (
|
||||
let response, responseTime;
|
||||
try {
|
||||
|
||||
let axiosInstance = makeAxiosInstance({ requestMaxRedirects: requestMaxRedirects, disableCookies: options.disableCookies });
|
||||
// Set timeout from request settings, default to 0 (no timeout)
|
||||
const requestTimeout = request.settings?.timeout || 0;
|
||||
if (requestTimeout > 0) {
|
||||
request.timeout = requestTimeout;
|
||||
}
|
||||
|
||||
let axiosInstance = makeAxiosInstance({
|
||||
requestMaxRedirects: requestMaxRedirects,
|
||||
disableCookies: options.disableCookies
|
||||
});
|
||||
|
||||
if (request.ntlmConfig) {
|
||||
axiosInstance=NtlmClient(request.ntlmConfig,axiosInstance.defaults)
|
||||
delete request.ntlmConfig;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (request.awsv4config) {
|
||||
// todo: make this happen in prepare-request.js
|
||||
|
||||
@@ -387,6 +387,16 @@ const importPostmanV2CollectionItem = (brunoParent, item, { useWorkers = false }
|
||||
encodeUrl: i.protocolProfileBehavior?.disableUrlEncoding !== true
|
||||
}
|
||||
|
||||
// Handle followRedirects setting
|
||||
if (i.protocolProfileBehavior?.followRedirects !== undefined) {
|
||||
settings.followRedirects = i.protocolProfileBehavior.followRedirects;
|
||||
}
|
||||
|
||||
// Handle maxRedirects setting
|
||||
if (i.protocolProfileBehavior?.maxRedirects !== undefined) {
|
||||
settings.maxRedirects = i.protocolProfileBehavior.maxRedirects;
|
||||
}
|
||||
|
||||
brunoRequestItem.settings = settings;
|
||||
|
||||
brunoParent.items.push(brunoRequestItem);
|
||||
|
||||
@@ -74,6 +74,88 @@ describe('postman-collection', () => {
|
||||
expect(brunoCollection.root.request.vars.req).toEqual([]);
|
||||
});
|
||||
|
||||
it('should correctly import protocolProfileBehavior settings from Postman requests', async () => {
|
||||
const collectionWithSettings = {
|
||||
info: {
|
||||
_postman_id: 'test-settings-id',
|
||||
name: 'Collection with Settings',
|
||||
schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json'
|
||||
},
|
||||
item: [
|
||||
{
|
||||
name: 'Request with all settings',
|
||||
protocolProfileBehavior: {
|
||||
maxRedirects: 10,
|
||||
followRedirects: false,
|
||||
disableUrlEncoding: true
|
||||
},
|
||||
request: {
|
||||
method: 'GET',
|
||||
header: [],
|
||||
url: {
|
||||
raw: 'https://httpbin.org/get',
|
||||
protocol: 'https',
|
||||
host: ['httpbin', 'org'],
|
||||
path: ['get']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Request with partial settings',
|
||||
protocolProfileBehavior: {
|
||||
followRedirects: true
|
||||
},
|
||||
request: {
|
||||
method: 'POST',
|
||||
header: [],
|
||||
url: {
|
||||
raw: 'https://httpbin.org/post',
|
||||
protocol: 'https',
|
||||
host: ['httpbin', 'org'],
|
||||
path: ['post']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Request without settings',
|
||||
request: {
|
||||
method: 'PUT',
|
||||
header: [],
|
||||
url: {
|
||||
raw: 'https://httpbin.org/put',
|
||||
protocol: 'https',
|
||||
host: ['httpbin', 'org'],
|
||||
path: ['put']
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const brunoCollection = await postmanToBruno(collectionWithSettings);
|
||||
|
||||
// Test request with all settings
|
||||
const requestWithAllSettings = brunoCollection.items[0];
|
||||
expect(requestWithAllSettings.settings).toEqual({
|
||||
encodeUrl: false,
|
||||
followRedirects: false,
|
||||
maxRedirects: 10
|
||||
});
|
||||
|
||||
// Test request with partial settings
|
||||
const requestWithPartialSettings = brunoCollection.items[1];
|
||||
expect(requestWithPartialSettings.settings).toEqual({
|
||||
encodeUrl: true,
|
||||
followRedirects: true
|
||||
});
|
||||
|
||||
// Test request without settings
|
||||
const requestWithoutSettings = brunoCollection.items[2];
|
||||
expect(requestWithoutSettings.settings).toEqual({
|
||||
encodeUrl: true
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle collection with auth object having undefined type', async () => {
|
||||
const collectionWithUndefinedAuthType = {
|
||||
'info': {
|
||||
|
||||
@@ -18,6 +18,7 @@ const prepareGqlIntrospectionRequest = require('./prepare-gql-introspection-requ
|
||||
const { prepareRequest } = require('./prepare-request');
|
||||
const interpolateVars = require('./interpolate-vars');
|
||||
const { makeAxiosInstance } = require('./axios-instance');
|
||||
const { resolveInheritedSettings } = require('../../utils/collection');
|
||||
const { cancelTokens, saveCancelToken, deleteCancelToken } = require('../../utils/cancel-token');
|
||||
const { uuid, safeStringifyJSON, safeParseJSON, parseDataFromResponse, parseDataFromRequest } = require('../../utils/common');
|
||||
const { chooseFileToSave, writeBinaryFile, writeFile } = require('../../utils/filesystem');
|
||||
@@ -89,14 +90,24 @@ const configureRequest = async (
|
||||
collectionPath
|
||||
});
|
||||
|
||||
let requestMaxRedirects = request.maxRedirects
|
||||
request.maxRedirects = 0
|
||||
// Get followRedirects setting, default to true for backward compatibility
|
||||
const followRedirects = request.settings?.followRedirects ?? true;
|
||||
|
||||
// Set default value for requestMaxRedirects if not explicitly set
|
||||
if (requestMaxRedirects === undefined) {
|
||||
// Get maxRedirects from request settings, fallback to request.maxRedirects, then default to 5
|
||||
let requestMaxRedirects = request.settings?.maxRedirects ?? request.maxRedirects ?? 5;
|
||||
|
||||
// Ensure it's a valid number
|
||||
if (typeof requestMaxRedirects !== 'number' || requestMaxRedirects < 0) {
|
||||
requestMaxRedirects = 5; // Default to 5 redirects
|
||||
}
|
||||
|
||||
// If followRedirects is disabled, set maxRedirects to 0 to disable all redirects
|
||||
if (!followRedirects) {
|
||||
requestMaxRedirects = 0;
|
||||
}
|
||||
|
||||
request.maxRedirects = 0;
|
||||
|
||||
let { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = certsAndProxyConfig;
|
||||
let axiosInstance = makeAxiosInstance({
|
||||
proxyMode,
|
||||
@@ -193,7 +204,9 @@ const configureRequest = async (
|
||||
addDigestInterceptor(axiosInstance, request);
|
||||
}
|
||||
|
||||
request.timeout = preferencesUtil.getRequestTimeout();
|
||||
// Get timeout from request settings, fallback to global preference
|
||||
const resolvedSettings = resolveInheritedSettings(request.settings || {});
|
||||
request.timeout = resolvedSettings.timeout;
|
||||
|
||||
// add cookies to request
|
||||
if (preferencesUtil.shouldSendCookies()) {
|
||||
@@ -276,7 +289,9 @@ const fetchGqlSchemaHandler = async (event, endpoint, environment, _request, col
|
||||
const collectionRoot = get(collection, 'root', {});
|
||||
const request = prepareGqlIntrospectionRequest(endpoint, resolvedVars, _request, collectionRoot);
|
||||
|
||||
request.timeout = preferencesUtil.getRequestTimeout();
|
||||
// Get timeout from request settings, resolve inheritance if needed
|
||||
const resolvedSettings = resolveInheritedSettings(request.settings || {});
|
||||
request.timeout = resolvedSettings.timeout;
|
||||
|
||||
if (!preferencesUtil.shouldVerifyTls()) {
|
||||
request.httpsAgent = new https.Agent({
|
||||
|
||||
@@ -3,6 +3,7 @@ const fs = require('fs');
|
||||
const { getRequestUid } = require('../cache/requestUids');
|
||||
const { uuid } = require('./common');
|
||||
const os = require('os');
|
||||
const { preferencesUtil } = require('../store/preferences');
|
||||
|
||||
const mergeHeaders = (collection, request, requestTreePath) => {
|
||||
let headers = new Map();
|
||||
@@ -523,6 +524,32 @@ const mergeAuth = (collection, request, requestTreePath) => {
|
||||
}
|
||||
};
|
||||
|
||||
const resolveInheritedSettings = (settings) => {
|
||||
const resolvedSettings = {};
|
||||
|
||||
// Resolve each setting individually
|
||||
Object.keys(settings).forEach((settingKey) => {
|
||||
const currentValue = settings[settingKey];
|
||||
|
||||
// If setting is inherited, fallback to preferences only for timeout setting
|
||||
if (currentValue === 'inherit' || currentValue === undefined || currentValue === null) {
|
||||
if (settingKey === 'timeout') {
|
||||
resolvedSettings[settingKey] = preferencesUtil.getRequestTimeout();
|
||||
}
|
||||
} else {
|
||||
// Use the current value as-is
|
||||
resolvedSettings[settingKey] = currentValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle missing timeout setting - if timeout is not in settings, treat it as inherited
|
||||
if (!settings.hasOwnProperty('timeout')) {
|
||||
resolvedSettings.timeout = preferencesUtil.getRequestTimeout();
|
||||
}
|
||||
|
||||
return resolvedSettings;
|
||||
};
|
||||
|
||||
const sortByNameThenSequence = items => {
|
||||
const isSeqValid = seq => Number.isFinite(seq) && Number.isInteger(seq) && seq > 0;
|
||||
|
||||
@@ -585,5 +612,6 @@ module.exports = {
|
||||
getAllRequestsInFolderRecursively,
|
||||
getEnvVars,
|
||||
getFormattedCollectionOauth2Credentials,
|
||||
sortByNameThenSequence
|
||||
sortByNameThenSequence,
|
||||
resolveInheritedSettings
|
||||
};
|
||||
@@ -427,20 +427,48 @@ const sem = grammar.createSemantics().addAttribute('ast', {
|
||||
|
||||
const keepAliveInterval = getNumFromRecord('keepAliveInterval');
|
||||
|
||||
const timeout = getNumFromRecord('timeout');
|
||||
const parsedSettings = {};
|
||||
if (settings.followRedirects !== undefined) {
|
||||
parsedSettings.followRedirects = typeof settings.followRedirects === 'boolean' ? settings.followRedirects : settings.followRedirects === 'true';
|
||||
}
|
||||
|
||||
// Parse maxRedirects as number
|
||||
if (settings.maxRedirects !== undefined) {
|
||||
const maxRedirects = parseInt(settings.maxRedirects, 10);
|
||||
if (!isNaN(maxRedirects)) {
|
||||
parsedSettings.maxRedirects = maxRedirects;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse timeout as number or inherit
|
||||
if (settings.timeout !== undefined) {
|
||||
if (settings.timeout === 'inherit') {
|
||||
parsedSettings.timeout = 'inherit';
|
||||
} else {
|
||||
const timeout = parseInt(settings.timeout, 10);
|
||||
if (!isNaN(timeout)) {
|
||||
parsedSettings.timeout = timeout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _settings = {
|
||||
encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true'
|
||||
encodeUrl: typeof settings.encodeUrl === 'boolean' ? settings.encodeUrl : settings.encodeUrl === 'true',
|
||||
timeout: parsedSettings.timeout !== undefined ? parsedSettings.timeout : 0
|
||||
};
|
||||
|
||||
if (parsedSettings.followRedirects !== undefined) {
|
||||
_settings.followRedirects = parsedSettings.followRedirects;
|
||||
}
|
||||
|
||||
if (parsedSettings.maxRedirects !== undefined) {
|
||||
_settings.maxRedirects = parsedSettings.maxRedirects;
|
||||
}
|
||||
|
||||
if (keepAliveInterval) {
|
||||
_settings.keepAliveInterval = keepAliveInterval;
|
||||
}
|
||||
|
||||
if (timeout) {
|
||||
_settings.timeout = timeout;
|
||||
}
|
||||
|
||||
return {
|
||||
settings: _settings
|
||||
};
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Settings All Options Test
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
put {
|
||||
url: https://api.example.com/all-options
|
||||
}
|
||||
|
||||
headers {
|
||||
content-type: application/json
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"test": "data"
|
||||
}
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
followRedirects: false
|
||||
maxRedirects: 0
|
||||
timeout: 60000
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Settings All Options Test",
|
||||
"type": "http",
|
||||
"seq": "3"
|
||||
},
|
||||
"http": {
|
||||
"method": "put",
|
||||
"url": "https://api.example.com/all-options"
|
||||
},
|
||||
"headers": [
|
||||
{
|
||||
"name": "content-type",
|
||||
"value": "application/json",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"json": "{\n \"test\": \"data\"\n}"
|
||||
},
|
||||
"settings": {
|
||||
"encodeUrl": true,
|
||||
"followRedirects": false,
|
||||
"maxRedirects": 0,
|
||||
"timeout": 60000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
meta {
|
||||
name: Settings Minimal Test
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: https://api.example.com/minimal
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: false
|
||||
timeout: 5000
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"meta": {
|
||||
"name": "Settings Minimal Test",
|
||||
"type": "http",
|
||||
"seq": "2"
|
||||
},
|
||||
"http": {
|
||||
"method": "post",
|
||||
"url": "https://api.example.com/minimal"
|
||||
},
|
||||
"settings": {
|
||||
"encodeUrl": false,
|
||||
"timeout": 5000
|
||||
}
|
||||
}
|
||||
58
packages/bruno-lang/v2/tests/settings/settings.spec.js
Normal file
58
packages/bruno-lang/v2/tests/settings/settings.spec.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bruToJson = require('../../src/bruToJson');
|
||||
const jsonToBru = require('../../src/jsonToBru');
|
||||
|
||||
describe('Settings Conversion Tests', () => {
|
||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||
|
||||
describe('parse (BRU to JSON)', () => {
|
||||
it('should parse minimal settings from BRU to JSON', () => {
|
||||
const input = fs.readFileSync(path.join(fixturesDir, 'settings-minimal.bru'), 'utf8');
|
||||
const expected = require(path.join(fixturesDir, 'settings-minimal.json'));
|
||||
const output = bruToJson(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should parse all settings options from BRU to JSON', () => {
|
||||
const input = fs.readFileSync(path.join(fixturesDir, 'settings-all-options.bru'), 'utf8');
|
||||
const expected = require(path.join(fixturesDir, 'settings-all-options.json'));
|
||||
const output = bruToJson(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringify (JSON to BRU)', () => {
|
||||
it('should stringify minimal settings from JSON to BRU (with defaults)', () => {
|
||||
const input = require(path.join(fixturesDir, 'settings-minimal.json'));
|
||||
const expected = fs.readFileSync(path.join(fixturesDir, 'settings-minimal.bru'), 'utf8');
|
||||
const output = jsonToBru(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should stringify all settings options from JSON to BRU', () => {
|
||||
const input = require(path.join(fixturesDir, 'settings-all-options.json'));
|
||||
const expected = fs.readFileSync(path.join(fixturesDir, 'settings-all-options.bru'), 'utf8');
|
||||
const output = jsonToBru(input);
|
||||
|
||||
expect(output).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
it('should maintain data integrity through JSON -> BRU -> JSON conversion', () => {
|
||||
const originalJson = require(path.join(fixturesDir, 'settings-all-options.json'));
|
||||
|
||||
// Convert JSON to BRU
|
||||
const bru = jsonToBru(originalJson);
|
||||
|
||||
// Convert BRU back to JSON
|
||||
const convertedJson = bruToJson(bru);
|
||||
|
||||
expect(convertedJson).toEqual(originalJson);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -490,7 +490,10 @@ const itemSchema = Yup.object({
|
||||
is: (type) => type === 'ws-request',
|
||||
then: wsSettingsSchema,
|
||||
otherwise: Yup.object({
|
||||
encodeUrl: Yup.boolean().nullable()
|
||||
encodeUrl: Yup.boolean().nullable(),
|
||||
followRedirects: Yup.boolean().nullable(),
|
||||
maxRedirects: Yup.number().min(0).max(50).nullable(),
|
||||
timeout: Yup.mixed().nullable(),
|
||||
}).noUnknown(true)
|
||||
.strict()
|
||||
.nullable()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: request-setting
|
||||
seq: 14
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
meta {
|
||||
name: follow-redirect
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/redirect/3
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
test("body should include redirecting", function() {
|
||||
const data = res.getBody();
|
||||
expect(data).to.include("Redirecting...");
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
followRedirects: false
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
meta {
|
||||
name: max-redirect
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/redirect/3
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
test("body should include redirecting", function() {
|
||||
const data = res.status;
|
||||
expect(data).to.be.equal(200)
|
||||
});
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
}
|
||||
9
tests/request/settings/collection/bruno.json
Normal file
9
tests/request/settings/collection/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "settings-test",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
||||
17
tests/request/settings/collection/max-redirects.bru
Normal file
17
tests/request/settings/collection/max-redirects.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: max-redirects-test
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/redirect/2
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
followRedirects: true
|
||||
maxRedirects: 1
|
||||
timeout: 0
|
||||
}
|
||||
17
tests/request/settings/collection/no-redirects.bru
Normal file
17
tests/request/settings/collection/no-redirects.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: no-redirects-test
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/redirect/2
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
followRedirects: false
|
||||
maxRedirects: 5
|
||||
timeout: 0
|
||||
}
|
||||
17
tests/request/settings/collection/timeout.bru
Normal file
17
tests/request/settings/collection/timeout.bru
Normal file
@@ -0,0 +1,17 @@
|
||||
meta {
|
||||
name: timeout-test
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: https://httpbun.com/redirect/2
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
followRedirects: false
|
||||
maxRedirects: 0
|
||||
timeout: 5
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"path": "{{projectRoot}}/tests/request/settings/collection",
|
||||
"securityConfig": {
|
||||
"jsSandboxMode": "safe"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
6
tests/request/settings/init-user-data/preferences.json
Normal file
6
tests/request/settings/init-user-data/preferences.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"maximized": true,
|
||||
"lastOpenedCollections": [
|
||||
"{{projectRoot}}/tests/request/settings/collection"
|
||||
]
|
||||
}
|
||||
46
tests/request/settings/max-redirects.spec.ts
Normal file
46
tests/request/settings/max-redirects.spec.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('Max Redirects Settings Tests', () => {
|
||||
test('should configure and test max redirects settings', async ({
|
||||
pageWithUserData: page
|
||||
}) => {
|
||||
// Navigate to the test collection and request
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible();
|
||||
|
||||
await page.locator('#sidebar-collection-name').getByText('settings-test').click();
|
||||
|
||||
// Navigate to the max-redirects request
|
||||
await page.getByRole('complementary').getByText('max-redirects').click();
|
||||
|
||||
// Go to Settings tab
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
|
||||
// Test Max Redirects Settings
|
||||
const maxRedirectsInput = page.locator('input[id="maxRedirects"]');
|
||||
await expect(maxRedirectsInput).toBeVisible();
|
||||
|
||||
// Verify default value from .bru file (1)
|
||||
await expect(maxRedirectsInput).toHaveValue('1');
|
||||
|
||||
// Test Follow Redirects toggle
|
||||
const followRedirectsToggle = page.getByTestId('follow-redirects-toggle');
|
||||
await expect(followRedirectsToggle).toBeVisible();
|
||||
await expect(followRedirectsToggle).toBeChecked();
|
||||
|
||||
// Send the request
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
await expect(page.getByTestId('response-status-code')).toContainText('302', { timeout: 15000 });
|
||||
|
||||
// change the max redirects to 2
|
||||
await maxRedirectsInput.fill('2');
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 15000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ pageWithUserData: page }) => {
|
||||
// Close the single open tab
|
||||
await page.locator('.close-icon-container').click();
|
||||
await page.locator('button:has-text("Don\'t Save")').first().click();
|
||||
});
|
||||
});
|
||||
50
tests/request/settings/no-redirects.spec.ts
Normal file
50
tests/request/settings/no-redirects.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
|
||||
test.describe('No Redirects Settings Tests', () => {
|
||||
test('should configure and test no redirects settings', async ({
|
||||
pageWithUserData: page
|
||||
}) => {
|
||||
// Navigate to the test collection and request
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible();
|
||||
|
||||
await page.locator('#sidebar-collection-name').getByText('settings-test').click();
|
||||
|
||||
// Navigate to the no-redirects request
|
||||
await page.getByRole('complementary').getByText('no-redirects').click();
|
||||
|
||||
// Go to Settings tab
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
|
||||
// Test No Redirects Settings
|
||||
const maxRedirectsInput = page.locator('input[id="maxRedirects"]');
|
||||
await expect(maxRedirectsInput).toBeVisible();
|
||||
|
||||
// Verify default value from .bru file (5)
|
||||
await expect(maxRedirectsInput).toHaveValue('5');
|
||||
|
||||
// Test Follow Redirects toggle - should be unchecked
|
||||
const followRedirectsToggle = page.getByTestId('follow-redirects-toggle');
|
||||
await expect(followRedirectsToggle).toBeVisible();
|
||||
await expect(followRedirectsToggle).not.toBeChecked();
|
||||
|
||||
// Send the request - should stop at first redirect (302) without following
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
// Should get 302 because redirects are disabled, regardless of maxRedirects value
|
||||
await expect(page.getByTestId('response-status-code')).toContainText('302', { timeout: 15000 });
|
||||
|
||||
// Toggle follow redirects to true
|
||||
await followRedirectsToggle.click();
|
||||
await expect(followRedirectsToggle).toBeChecked();
|
||||
|
||||
// Send request again - now should follow redirects and get 200
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
await expect(page.getByTestId('response-status-code')).toContainText('200', { timeout: 15000 });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ pageWithUserData: page }) => {
|
||||
// Close the single open tab
|
||||
await page.locator('.close-icon-container').click();
|
||||
await page.locator('button:has-text("Don\'t Save")').first().click();
|
||||
});
|
||||
});
|
||||
38
tests/request/settings/timeout.spec.ts
Normal file
38
tests/request/settings/timeout.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { test, expect } from '../../../playwright';
|
||||
import { closeAllCollections } from '../../utils/page';
|
||||
|
||||
test.describe('Timeout Settings Tests', () => {
|
||||
test('should configure and test timeout settings', async ({
|
||||
pageWithUserData: page
|
||||
}) => {
|
||||
// Navigate to the test collection and request
|
||||
await expect(page.locator('#sidebar-collection-name').getByText('settings-test')).toBeVisible();
|
||||
|
||||
await page.locator('#sidebar-collection-name').getByText('settings-test').click();
|
||||
// Navigate to thetimeout request
|
||||
await page.getByRole('complementary').getByText('timeout-test').click();
|
||||
|
||||
// Go to Settings tab
|
||||
await page.getByRole('tab', { name: 'Settings' }).click();
|
||||
|
||||
// Test Timeout Settings
|
||||
const timeoutInput = page.locator('input[id="timeout"]');
|
||||
await expect(timeoutInput).toBeVisible();
|
||||
|
||||
// Verify default value from .bru file (5)
|
||||
await expect(timeoutInput).toHaveValue('5');
|
||||
|
||||
await page.getByTestId('send-arrow-icon').click();
|
||||
|
||||
const responsePane = page.locator('.response-pane');
|
||||
await expect(responsePane).toContainText('timeout of 5ms exceeded');
|
||||
|
||||
// go to welcome page
|
||||
await page.locator('.bruno-logo').click();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ pageWithUserData: page }) => {
|
||||
// cleanup: close all collections
|
||||
await closeAllCollections(page);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user