diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js index b4bc6c360..f96d313a2 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/CollectionStatusSection/index.js @@ -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 = ({ )} + {hasDrift && ( +
+ + What's tracked: Changes to URL, parameters, headers, body and auth compared to the synced spec. Your variables, scripts, tests, assertions, settings etc. are not tracked here. +
+ )} + {hasDrift ? (
{/* Modified in Collection */} diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js index 9623dd154..829a0eb2e 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/ConfirmSyncModal/index.js @@ -74,7 +74,7 @@ const ConfirmSyncModal = ({ groups, onCancel, onSync, isSyncing }) => { )}
-
- +
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js index 2bfe9f310..5cf6eeb1d 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/OpenAPISyncHeader/index.js @@ -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 = ({
{title} - {version}
diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js index 527743bfa..122a6bdc2 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/StyledWrapper.js @@ -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; } diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js index d1cdf86fc..1e7bf328f 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/hooks/useOpenAPISync.js @@ -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, diff --git a/packages/bruno-app/src/components/OpenAPISyncTab/index.js b/packages/bruno-app/src/components/OpenAPISyncTab/index.js index 9c6fdceee..58baae376 100644 --- a/packages/bruno-app/src/components/OpenAPISyncTab/index.js +++ b/packages/bruno-app/src/components/OpenAPISyncTab/index.js @@ -85,7 +85,7 @@ const OpenAPISyncTab = ({ collection }) => { return ( -
+
{/* 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 && ( <>
diff --git a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js index 6ee77b57b..dc93ae17d 100644 --- a/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js +++ b/packages/bruno-app/src/components/RequestTabs/CollectionHeader/index.js @@ -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) diff --git a/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js b/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js index 259a96a86..edb34a118 100644 --- a/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js +++ b/packages/bruno-app/src/ui/StatusBadge/StyledWrapper.js @@ -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; diff --git a/packages/bruno-app/src/ui/StatusBadge/index.js b/packages/bruno-app/src/ui/StatusBadge/index.js index c0abf8ed1..c29b95f32 100644 --- a/packages/bruno-app/src/ui/StatusBadge/index.js +++ b/packages/bruno-app/src/ui/StatusBadge/index.js @@ -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) diff --git a/packages/bruno-app/src/utils/importers/file-reader.js b/packages/bruno-app/src/utils/importers/file-reader.js index fd12c81a7..fb95445a1 100644 --- a/packages/bruno-app/src/utils/importers/file-reader.js +++ b/packages/bruno-app/src/utils/importers/file-reader.js @@ -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(); diff --git a/packages/bruno-app/src/utils/url/index.js b/packages/bruno-app/src/utils/url/index.js index 8fbf2ff49..d84451bf9 100644 --- a/packages/bruno-app/src/utils/url/index.js +++ b/packages/bruno-app/src/utils/url/index.js @@ -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; diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js index 11c29919c..4843486b3 100644 --- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js +++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js @@ -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)) { diff --git a/packages/bruno-electron/src/ipc/openapi-sync.js b/packages/bruno-electron/src/ipc/openapi-sync.js index 6e6693f61..67fdfe020 100644 --- a/packages/bruno-electron/src/ipc/openapi-sync.js +++ b/packages/bruno-electron/src/ipc/openapi-sync.js @@ -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 } );