enhance OpenAPI sync with validation, enum support, and bug fixes (#7408)

* Enhance OpenAPISyncTab functionality with error handling and UI improvements

- Updated ConnectSpecForm to include error handling for invalid OpenAPI specifications when uploading files.
- Added a sync info notice in CollectionStatusSection to inform users about tracked changes.
- Improved styling in StyledWrapper for better visual feedback and layout consistency.
- Adjusted button colors and properties in ConfirmSyncModal and ConnectionSettingsModal for better UX.
- Refactored useOpenAPISync hook to validate URLs before syncing, ensuring only valid OpenAPI specs are processed.
- Enhanced parameter handling in openapi-to-bruno.js to support enum and default values more effectively.

* Refactor OpenAPISyncTab components for improved URL validation and error handling

- Updated ConnectSpecForm to streamline file upload error handling for OpenAPI specifications.
- Enhanced OpenAPISyncHeader to utilize isHttpUrl for better URL validation.
- Refactored useOpenAPISync hook to replace isValidUrl with isHttpUrl for consistency in URL checks.
- Improved file parsing logic in file-reader.js to handle case-insensitive JSON file extensions.
- Added isHttpUrl utility function to validate HTTP/HTTPS URLs effectively.

* Enhance file parsing logic in file-reader.js to improve error handling for JSON and YAML files

- Updated parseFileAsJsonOrYaml function to handle case-insensitive JSON file extensions more robustly.
- Added error handling to ensure the document root is an object and not an array, improving data validation.

* Update StatusBadge component to include new 'xs' size preset and adjust documentation accordingly

- Added 'xs' size preset with specific font size and padding for minimal use cases.
- Updated documentation to reflect the new size options available for the StatusBadge component.
This commit is contained in:
Abhishek S Lal
2026-03-10 17:15:45 +05:30
committed by GitHub
parent f123a2b574
commit 4d17809562
15 changed files with 368 additions and 97 deletions

View File

@@ -5,7 +5,8 @@ import {
IconTrash,
IconArrowBackUp,
IconExternalLink,
IconClock
IconClock,
IconInfoCircle
} from '@tabler/icons';
import moment from 'moment';
import Button from 'ui/Button';
@@ -109,6 +110,13 @@ const CollectionStatusSection = ({
</div>
)}
{hasDrift && (
<div className="sync-info-notice mt-4">
<IconInfoCircle size={14} className="sync-info-icon" />
<span><span className="whats-updated-title">What's tracked:</span> Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here.</span>
</div>
)}
{hasDrift ? (
<div className="mt-5">
{/* Modified in Collection */}

View File

@@ -74,7 +74,7 @@ const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => {
)}
<div className="sync-confirm-actions">
<Button variant="ghost" onClick={onCancel}>
<Button variant="ghost" color="secondary" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSync} loading={isSyncing} disabled={isSyncing}>

View File

@@ -1,6 +1,9 @@
import { useState, useRef } from 'react';
import { IconCheck } from '@tabler/icons';
import Button from 'ui/Button';
import { isValidUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const FEATURES = [
'Detect new, modified, and removed endpoints',
@@ -9,7 +12,7 @@ const FEATURES = [
'Your tests, assertions, and scripts are preserved during sync'
];
const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, onConnect }) => {
const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, error, setError, onConnect }) => {
const [mode, setMode] = useState('url');
const fileInputRef = useRef(null);
@@ -66,11 +69,21 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, onConnect }) => {
type="file"
accept=".json,.yaml,.yml"
style={{ display: 'none' }}
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
if (!file) return;
setError(null);
setSourceUrl('');
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
setError('The selected file is not a valid OpenAPI specification');
return;
}
const filePath = window.ipcRenderer.getFilePath(file);
if (filePath) setSourceUrl(filePath);
} catch (err) {
setError(err.message || 'Failed to read the selected file');
}
}}
/>
@@ -87,7 +100,7 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, onConnect }) => {
<Button
type="submit"
size="sm"
disabled={!sourceUrl.trim()}
disabled={mode === 'url' ? !isValidUrl(sourceUrl.trim()) : !sourceUrl.trim()}
loading={isLoading}
>
Connect
@@ -98,6 +111,9 @@ const ConnectSpecForm = ({ sourceUrl, setSourceUrl, isLoading, onConnect }) => {
? 'Supports OpenAPI 3.x specifications in JSON or YAML format'
: 'Select a local OpenAPI/Swagger JSON or YAML file'}
</p>
{error && (
<p className="setup-error">{error}</p>
)}
</form>
<div className="setup-features">

View File

@@ -1,7 +1,10 @@
import { useState, useRef } from 'react';
import toast from 'react-hot-toast';
import Button from 'ui/Button';
import Modal from 'components/Modal';
import { isValidUrl } from 'utils/url/index';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import { parseFileAsJsonOrYaml } from 'utils/importers/file-reader';
const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect, onClose }) => {
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
@@ -75,11 +78,20 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
type="file"
accept=".json,.yaml,.yml"
style={{ display: 'none' }}
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
const path = window.ipcRenderer.getFilePath(file);
if (path) setFilePath(path);
try {
const data = await parseFileAsJsonOrYaml(file);
if (!isOpenApiSpec(data)) {
toast.error('The selected file is not a valid OpenAPI specification');
return;
}
const path = window.ipcRenderer.getFilePath(file);
if (path) setFilePath(path);
} catch (err) {
toast.error(err.message || 'Failed to read the selected file');
}
}
}}
/>
@@ -136,7 +148,7 @@ const ConnectionSettingsModal = ({ collection, sourceUrl, onSave, onDisconnect,
Disconnect sync
</button>
<div className="settings-actions">
<Button variant="ghost" size="sm" onClick={onClose}>Cancel</Button>
<Button variant="ghost" color="secondary" size="sm" onClick={onClose}>Cancel</Button>
<Button size="sm" onClick={handleSave} loading={isSaving} disabled={!canSave || isSaving}>Save</Button>
</div>
</div>

View File

@@ -13,13 +13,14 @@ import StatusBadge from 'ui/StatusBadge';
import ActionIcon from 'ui/ActionIcon/index';
import MenuDropdown from 'ui/MenuDropdown';
import Help from 'components/Help';
import { isHttpUrl } from 'utils/url/index';
const OpenAPISyncHeader = ({
collection, spec, sourceUrl, syncStatus, onViewSpec,
onOpenSettings, onOpenDisconnect,
onCheck, isLoading
}) => {
const sourceIsLocal = !sourceUrl?.startsWith('http');
const sourceIsLocal = !isHttpUrl(sourceUrl);
const canCheck = !!sourceUrl?.trim();
const title = spec?.info?.title || 'Unknown API';
@@ -74,7 +75,6 @@ const OpenAPISyncHeader = ({
<div className="spec-title-section">
<div className="spec-title-row">
<span className="spec-title">{title}</span>
<StatusBadge status="muted" variant="outline" className="spec-version">{version}</StatusBadge>
</div>
</div>
<div className="spec-header-actions">

View File

@@ -68,6 +68,12 @@ const StyledWrapper = styled.div`
margin: 0.5rem 0 0 0;
}
.setup-error {
font-size: ${(props) => props.theme.font.size.sm};
color: ${(props) => props.theme.colors.text.danger};
margin: 0.5rem 0 0 0;
}
.setup-features {
display: flex;
flex-direction: column;
@@ -137,6 +143,7 @@ const StyledWrapper = styled.div`
align-items: center;
gap: 6px;
font-size: 11px;
margin-top: 0.35rem;
.spec-url-value {
font-family: monospace;
@@ -339,6 +346,14 @@ const StyledWrapper = styled.div`
font-size: 13px;
font-weight: 500;
color: ${(props) => props.theme.text};
svg {
opacity: 0.3;
}
&:hover svg {
opacity: 0.6;
}
}
}
@@ -522,6 +537,7 @@ const StyledWrapper = styled.div`
.sync-summary-cards {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

View File

@@ -4,6 +4,8 @@ import toast from 'react-hot-toast';
import { addTab, focusTab, closeTabs } from 'providers/ReduxStore/slices/tabs';
import { getDefaultRequestPaneTab } from 'utils/collections';
import { clearCollectionState, setCollectionUpdate } from 'providers/ReduxStore/slices/openapi-sync';
import { fetchAndValidateApiSpecFromUrl } from 'utils/importers/common';
import { isHttpUrl } from 'utils/url/index';
import { flattenItems } from 'utils/collections/index';
import { formatIpcError } from 'utils/common/error';
@@ -193,7 +195,8 @@ const useOpenAPISync = (collection) => {
}, [httpItemCount, isConfigured]);
const handleConnect = async () => {
if (!sourceUrl.trim()) {
const trimmedUrl = sourceUrl.trim();
if (!trimmedUrl) {
setError('Please enter a URL or select a file');
return;
}
@@ -203,13 +206,27 @@ const useOpenAPISync = (collection) => {
setFileNotFound(false);
try {
// Validate it's a valid OpenAPI spec before proceeding (URL only; files are validated at picker)
if (isHttpUrl(trimmedUrl)) {
try {
const { specType } = await fetchAndValidateApiSpecFromUrl({ url: trimmedUrl });
if (specType !== 'openapi') {
setError('The URL does not point to a valid OpenAPI specification');
return;
}
} catch {
setError('The URL does not point to a valid OpenAPI specification');
return;
}
}
const { ipcRenderer } = window;
// Validate the spec first
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
collectionUid: collection.uid,
collectionPath: collection.pathname,
sourceUrl: sourceUrl.trim(),
sourceUrl: trimmedUrl,
environmentContext: {
activeEnvironmentUid: collection.activeEnvironmentUid,
environments: collection.environments,
@@ -228,7 +245,7 @@ const useOpenAPISync = (collection) => {
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
config: {
sourceUrl: sourceUrl.trim(),
sourceUrl: trimmedUrl,
groupBy: 'tags',
autoCheck: true,
autoCheckInterval: 5
@@ -253,7 +270,7 @@ const useOpenAPISync = (collection) => {
await ipcRenderer.invoke('renderer:save-openapi-spec', {
collectionPath: collection.pathname,
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
sourceUrl: sourceUrl.trim()
sourceUrl: trimmedUrl
});
}
}
@@ -302,8 +319,27 @@ const useOpenAPISync = (collection) => {
// Save connection settings from the modal
const handleSaveSettings = async ({ sourceUrl: newUrl, autoCheck, autoCheckInterval }) => {
const sourceUrlChanged = newUrl !== openApiSyncConfig?.sourceUrl;
// Validate the spec before saving if source URL changed (URL only; files are validated at picker)
// Kept outside try-catch so validation errors propagate to the caller and the modal stays open
if (sourceUrlChanged && isHttpUrl(newUrl)) {
let specType;
try {
({ specType } = await fetchAndValidateApiSpecFromUrl({ url: newUrl }));
} catch {
toast.error('The URL does not point to a valid OpenAPI specification');
throw new Error('Invalid OpenAPI specification');
}
if (specType !== 'openapi') {
toast.error('The URL does not point to a valid OpenAPI specification');
throw new Error('Invalid OpenAPI specification');
}
}
try {
const { ipcRenderer } = window;
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
collectionPath: collection.pathname,
oldSourceUrl: openApiSyncConfig?.sourceUrl,

View File

@@ -85,7 +85,7 @@ const OpenAPISyncTab = ({ collection }) => {
return (
<StyledWrapper className="flex flex-col h-full relative px-4 pt-4 overflow-auto">
<div className="sync-page max-w-screen-xl">
<div className="sync-page w-full">
{/* Setup form when not configured */}
{!isConfigured && (
@@ -93,6 +93,8 @@ const OpenAPISyncTab = ({ collection }) => {
sourceUrl={sourceUrl}
setSourceUrl={setSourceUrl}
isLoading={isLoading}
error={error}
setError={setError}
onConnect={handleConnect}
/>
)}
@@ -148,7 +150,7 @@ const OpenAPISyncTab = ({ collection }) => {
lastSyncDate={openApiSyncConfig?.lastSyncDate}
onOpenEndpoint={openEndpointInTab}
/>
) : !isDriftLoading && (
) : !isDriftLoading && !isLoading && (
<>
<div className="spec-update-banner warning">
<div className="banner-left">

View File

@@ -201,10 +201,10 @@ const CollectionHeader = ({ collection, isScratchCollection }) => {
// Build overflow menu items for the "..." dropdown
const overflowMenuItems = [
{ id: 'variables', label: 'Variables', leftSection: IconEye, onClick: viewVariables },
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings },
...(!hasOpenApiSyncConfigured
? [{ id: 'openapi-sync', label: 'OpenAPI Sync', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
: [])
? [{ id: 'openapi-sync', label: 'OpenAPI', leftSection: OpenAPISyncIcon, onClick: viewOpenApiSync }]
: []),
{ id: 'collection-settings', label: 'Collection Settings', leftSection: IconSettings, onClick: viewCollectionSettings }
];
// Workspace action handlers (only used when isScratchCollection is true)

View File

@@ -90,10 +90,15 @@ const resolveRadius = (props) => {
/**
* Size presets — derived from existing badge patterns in the codebase.
*
* - xs: 9px font, minimal padding (inline labels, tab badges)
* - sm: 10px font, compact padding (matches .conflict-badge, .source-tag, .required-badge)
* - md: theme xs font, wider padding (matches .deprecated-tag, .changes-tag, .context-pill)
*/
const sizeStyles = {
xs: css`
font-size: 9px;
padding: 0.0625rem 0.25rem;
`,
sm: css`
font-size: 10px;
padding: 0.125rem 0.375rem;

View File

@@ -8,7 +8,7 @@ import StyledWrapper from './StyledWrapper';
* - children: badge text content
* - status: theme status key — 'danger' | 'warning' | 'info' | 'success' | 'muted' (default: 'muted')
* - variant: visual style — 'light' | 'filled' | 'outline' | 'ghost' (default: 'light')
* - size: size preset — 'sm' | 'md' (default: 'sm')
* - size: size preset — 'xs' | 'sm' | 'md' (default: 'sm')
* - radius: theme radius key ('sm','base','md','lg','xl') or CSS value (default: theme sm)
* - leftSection: ReactNode rendered before children (e.g. icon)
* - rightSection: ReactNode rendered after children (e.g. Help tooltip)

View File

@@ -1,5 +1,28 @@
import jsyaml from 'js-yaml';
import { BrunoError } from 'utils/common/error';
/**
* Parse a File object as JSON or YAML and return the parsed object.
* Throws with a user-friendly message on parse failure.
*/
export const parseFileAsJsonOrYaml = async (file) => {
try {
const text = await file.text();
let parsed;
if (file.name.toLowerCase().endsWith('.json')) {
parsed = JSON.parse(text);
} else {
parsed = jsyaml.load(text);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Document root must be an object');
}
return parsed;
} catch {
throw new Error('Failed to parse the file ensure it is valid JSON or YAML');
}
};
const readFile = (file) => {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();

View File

@@ -90,6 +90,15 @@ export const isValidUrl = (url) => {
}
};
export const isHttpUrl = (url) => {
try {
const parsed = new URL(url);
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
} catch {
return false;
}
};
export const interpolateUrl = ({ url, variables }) => {
if (!url || !url.length || typeof url !== 'string') {
return;

View File

@@ -507,38 +507,134 @@ const getSchemaPropertyExampleValue = (prop, propName, parentExample = {}) => {
return '';
};
// Extract a representative value from an OpenAPI parameter object (query/path/header)
// Priority: param.example > param.examples > array items > schema.default > schema.example > schema.enum > schema.examples > schema.minimum > ''
const getParameterExampleValue = (param) => {
// Top-level param examples (mutually exclusive per spec)
if (param.example !== undefined) return String(param.example);
if (param.examples) {
/**
* Extracts parameter entries based on OpenAPI parameter schema
* For enum parameters, creates multiple entries (one per enum value)
* Handles enum, default, constant, nullable, and array types per Swagger spec
* @param {Object} param - The OpenAPI parameter object
* @returns {Array} - Array of objects with value and enabled properties
*/
const getParameterEntries = (param) => {
const schema = param.schema || {};
const entries = [];
// Handle enum parameters - create entry for each enum value
if (schema.enum && Array.isArray(schema.enum) && schema.enum.length > 0) {
const defaultValue = schema.default !== undefined ? String(schema.default) : null;
schema.enum.forEach((enumValue) => {
const valueStr = String(enumValue);
// Enable only if it matches the default value, or if it's the first value and required
const isDefault = defaultValue !== null && valueStr === defaultValue;
const enabled = isDefault || (defaultValue === null && schema.enum.indexOf(enumValue) === 0 && !!param.required);
entries.push({
value: valueStr,
enabled: enabled
});
});
return entries;
}
// Handle array type with items schema that has enum
if (schema.type === 'array' && schema.items && schema.items.enum && Array.isArray(schema.items.enum) && schema.items.enum.length > 0) {
const defaultValue = schema.items.default !== undefined ? String(schema.items.default) : null;
const arrayDefault = schema.default !== undefined && Array.isArray(schema.default) ? schema.default : null;
// If there's a default at array level, use it
if (arrayDefault) {
entries.push({
value: JSON.stringify(arrayDefault),
enabled: true
});
return entries;
}
// Otherwise, create entries for each enum value in items
schema.items.enum.forEach((enumValue) => {
const valueStr = String(enumValue);
const isDefault = defaultValue !== null && valueStr === defaultValue;
const enabled = isDefault || (defaultValue === null && schema.items.enum.indexOf(enumValue) === 0 && !!param.required);
entries.push({
value: valueStr,
enabled: enabled
});
});
return entries;
}
// For non-enum cases, return single entry with comprehensive value extraction
// Merges HEAD's detailed handling with MERGE_HEAD's broader example sources
let value = '';
let enabled = param.required || false;
// Priority 1: Top-level param examples (from upstream, mutually exclusive per spec)
if (param.example !== undefined) {
value = String(param.example);
enabled = true;
} else if (param.examples) {
const firstExample = Object.values(param.examples)[0];
if (firstExample?.value !== undefined) return String(firstExample.value);
if (firstExample?.value !== undefined) {
value = String(firstExample.value);
enabled = true;
}
}
// Array type - return first item as representative value
if (param.schema?.type === 'array' && param.schema?.items) {
const itemExample = param.schema.items.example
?? param.schema.items.enum?.[0]
?? '';
return String(itemExample);
// Priority 2: schema.default (from HEAD, handles array defaults with JSON.stringify)
if (value === '' && schema.default !== undefined) {
if (schema.type === 'array' && Array.isArray(schema.default)) {
value = JSON.stringify(schema.default);
} else {
value = String(schema.default);
}
enabled = true;
}
// Schema-level fallback values
if (param.schema?.default !== undefined) return String(param.schema.default);
if (param.schema?.example !== undefined) return String(param.schema.example);
if (param.schema?.enum && param.schema.enum.length > 0) return String(param.schema.enum[0]);
// schema.examples is a plain JSON Schema array of values (OAS 3.1+)
if (Array.isArray(param.schema?.examples) && param.schema.examples.length > 0) {
return String(param.schema.examples[0]);
// Priority 3: schema.example (from upstream)
if (value === '' && schema.example !== undefined) {
value = String(schema.example);
enabled = true;
}
// Use minimum as a sensible fallback for numeric types
if (param.schema?.minimum !== undefined) return String(param.schema.minimum);
// Priority 4: Array type handling (merged from both sides)
if (value === '' && schema.type === 'array' && schema.items) {
if (schema.items.example !== undefined) {
value = String(schema.items.example);
} else if (schema.items.enum && schema.items.enum.length > 0) {
value = String(schema.items.enum[0]);
} else if (schema.items.default !== undefined) {
value = String(schema.items.default);
} else {
value = '[]';
}
enabled = param.required || false;
}
return '';
// Priority 5: schema.examples (OAS 3.1+, from upstream)
if (value === '' && Array.isArray(schema.examples) && schema.examples.length > 0) {
value = String(schema.examples[0]);
enabled = true;
}
// Priority 6: schema.minimum fallback for numeric types (from upstream)
if (value === '' && schema.minimum !== undefined) {
value = String(schema.minimum);
enabled = param.required || false;
}
// Priority 7: Edge cases (from HEAD)
if (value === '') {
if (schema.nullable === true && !param.required) {
enabled = false;
} else if (param.allowEmptyValue === true && !param.required) {
enabled = false;
}
}
return [{ value, enabled }];
};
const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {}) => {
@@ -634,66 +730,78 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
each(param.schema.properties, (prop, propName) => {
const isRequired = Array.isArray(param.schema.required) && param.schema.required.includes(propName);
const propValue = getSchemaPropertyExampleValue(prop, propName, schemaExample);
// Create a temporary parameter object for getParameterEntries
// Enrich property with parent example context if property lacks its own example
// Use child-level example only; drop parent-level example/examples to avoid
// object-level values leaking into scalar child parameters
const propSchema = (prop.example === undefined && schemaExample[propName] !== undefined)
? { ...prop, example: schemaExample[propName] }
: prop;
const tempParam = { ...param, example: undefined, examples: undefined, name: propName, schema: propSchema, required: isRequired };
const entries = getParameterEntries(tempParam);
entries.forEach((entry) => {
if (param.in === 'query' || param.in === 'querystring') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: entry.value,
description: prop.description || '',
enabled: entry.enabled,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: entry.value,
description: prop.description || '',
enabled: entry.enabled,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: propName,
value: entry.value,
description: prop.description || '',
enabled: entry.enabled
});
}
});
});
} else {
const entries = getParameterEntries(param);
entries.forEach((entry) => {
if (param.in === 'query' || param.in === 'querystring') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: propValue,
description: prop.description || '',
enabled: isRequired,
name: param.name,
value: entry.value,
description: param.description || '',
enabled: entry.enabled,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: propName,
value: propValue,
description: prop.description || '',
enabled: isRequired,
name: param.name,
value: entry.value,
description: param.description || '',
enabled: entry.enabled,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: propName,
value: propValue,
description: prop.description || '',
enabled: isRequired
name: param.name,
value: entry.value,
description: param.description || '',
enabled: entry.enabled
});
}
});
} else {
const paramValue = getParameterExampleValue(param);
if (param.in === 'query' || param.in === 'querystring') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: paramValue,
description: param.description || '',
enabled: param.required,
type: 'query'
});
} else if (param.in === 'path') {
brunoRequestItem.request.params.push({
uid: uuid(),
name: param.name,
value: paramValue,
description: param.description || '',
enabled: param.required,
type: 'path'
});
} else if (param.in === 'header') {
brunoRequestItem.request.headers.push({
uid: uuid(),
name: param.name,
value: paramValue,
description: param.description || '',
enabled: param.required
});
}
}
});
@@ -1040,6 +1148,15 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = {
return brunoRequestItem;
};
// Helper function to validate $ref
const isValidRef = (ref) => {
if (typeof ref !== 'string') {
return false;
}
return ref.startsWith('#/components/');
};
const resolveRefs = (spec, components = spec?.components, cache = new Map()) => {
if (!spec || typeof spec !== 'object') {
return spec;
@@ -1053,7 +1170,10 @@ const resolveRefs = (spec, components = spec?.components, cache = new Map()) =>
return spec.map((item) => resolveRefs(item, components, cache));
}
if ('$ref' in spec) {
// Only treat as a JSON reference if it passes all validation checks
const isRef = isValidRef(spec.$ref);
if (isRef) {
const refPath = spec.$ref;
if (cache.has(refPath)) {

View File

@@ -39,6 +39,19 @@ const isYamlContent = (content) => {
}
};
/**
* Pretty-print JSON content for readable diffs. YAML content is returned as-is.
*/
const prettyPrintSpec = (content) => {
if (!content) return '';
try {
const parsed = JSON.parse(content);
return JSON.stringify(parsed, null, 2);
} catch {
return content;
}
};
/**
* Generate an MD5 hash of a parsed OpenAPI spec for quick change detection.
*/
@@ -400,12 +413,15 @@ const cleanupSpecFilesForCollection = (collectionPath) => {
/**
* Merge spec params/headers with existing user values.
* For each spec item, preserves the user's value and enabled state if a matching name exists.
* Matches by name + value to correctly handle enum-expanded params (multiple entries with same name).
* Only preserves the user's enabled state; values come from the spec.
*/
const mergeWithUserValues = (specItems, existingItems) => {
return specItems?.map((specItem) => {
const existing = (existingItems || []).find((e) => e.name === specItem.name);
return existing ? { ...specItem, value: existing.value, enabled: existing.enabled } : specItem;
const existing = (existingItems || []).find(
(e) => e.name === specItem.name && e.value === specItem.value
);
return existing ? { ...specItem, enabled: existing.enabled } : specItem;
});
};
@@ -447,10 +463,16 @@ const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } =
/**
* Ensure a tag-based folder exists in the collection directory.
* Creates the folder and its folder.bru/folder.yml file if missing.
* Returns the resolved target folder path (falls back to collectionPath on path traversal).
* Returns the resolved target folder path (falls back to collectionPath on reserved/traversal names).
*/
const RESERVED_FOLDER_NAMES = ['node_modules', '.git', 'environments'];
const ensureTagFolder = async (collectionPath, folderName, format) => {
const safeFolderName = sanitizeName(folderName);
if (RESERVED_FOLDER_NAMES.some((r) => r.toLowerCase() === safeFolderName.toLowerCase())) {
console.warn(`[OpenAPI Sync] Tag "${folderName}" sanitizes to reserved folder name "${safeFolderName}", placing requests in collection root`);
return collectionPath;
}
const targetFolder = path.join(collectionPath, safeFolderName);
if (!isPathInsideCollection(targetFolder, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked in folder name: ${folderName}`);
@@ -741,13 +763,15 @@ const registerOpenAPISyncIpc = (mainWindow) => {
// Generate unified diff for text diff view
const { createTwoFilesPatch } = require('diff');
const prettyStored = prettyPrintSpec(storedContent);
const prettyNew = prettyPrintSpec(newSpecContent);
const totalLines = Math.max(
(storedContent || '').split('\n').length,
newSpecContent.split('\n').length
prettyStored.split('\n').length,
prettyNew.split('\n').length
);
const unifiedDiff = createTwoFilesPatch(
correctSpecFilename, correctSpecFilename,
storedContent || '', newSpecContent,
prettyStored, prettyNew,
'Current Spec', 'New Spec',
{ context: totalLines }
);