mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user