mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 22:54:07 +00:00
* 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.
386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { useDispatch, useSelector } from 'react-redux';
|
|
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';
|
|
|
|
const useOpenAPISync = (collection) => {
|
|
const dispatch = useDispatch();
|
|
const openApiSyncConfig = collection?.brunoConfig?.openapi?.[0];
|
|
|
|
// Core state
|
|
const [sourceUrl, setSourceUrl] = useState(openApiSyncConfig?.sourceUrl || '');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState(null);
|
|
const [fileNotFound, setFileNotFound] = useState(false);
|
|
const [specDrift, setSpecDrift] = useState(null);
|
|
// Collection drift state
|
|
const [collectionDrift, setCollectionDrift] = useState(null);
|
|
const [remoteDrift, setRemoteDrift] = useState(null);
|
|
const [isDriftLoading, setIsDriftLoading] = useState(false);
|
|
const [storedSpec, setStoredSpec] = useState(null);
|
|
|
|
const tabs = useSelector((state) => state.tabs.tabs);
|
|
|
|
const isConfigured = !!openApiSyncConfig?.sourceUrl;
|
|
|
|
// Flatten collection items including nested items in folders
|
|
const allHttpItems = useMemo(() => {
|
|
return flattenItems(collection?.items || []).filter((item) => item.type === 'http-request');
|
|
}, [collection?.items]);
|
|
|
|
const httpItemCount = useMemo(() => {
|
|
return String(allHttpItems.filter((item) => !item.partial && !item.loading).length);
|
|
}, [allHttpItems]);
|
|
|
|
// Map endpoint drift id (METHOD:path) → collection item uid
|
|
const endpointUidMap = useMemo(() => {
|
|
const normalize = (url) => (url || '')
|
|
.replace(/\{\{[^}]+\}\}/g, '')
|
|
.replace(/^https?:\/\/[^/]+/, '')
|
|
.replace(/\?.*$/, '')
|
|
.replace(/{([^}]+)}/g, ':$1')
|
|
.replace(/\/+/g, '/')
|
|
.replace(/\/$/, '');
|
|
const map = {};
|
|
allHttpItems.forEach((item) => {
|
|
if (item.request?.method && item.request?.url) {
|
|
const key = `${item.request.method.toUpperCase()}:${normalize(item.request.url)}`;
|
|
map[key] = item.uid;
|
|
}
|
|
});
|
|
return map;
|
|
}, [allHttpItems]);
|
|
|
|
// Open an endpoint in a tab (focus existing or add new), same as sidebar click
|
|
const openEndpointInTab = (endpointId) => {
|
|
const itemUid = endpointUidMap[endpointId];
|
|
if (!itemUid) return;
|
|
const existingTab = tabs.find((t) => t.uid === itemUid);
|
|
if (existingTab) {
|
|
dispatch(focusTab({ uid: itemUid }));
|
|
} else {
|
|
const item = allHttpItems.find((i) => i.uid === itemUid);
|
|
dispatch(addTab({
|
|
uid: itemUid,
|
|
collectionUid: collection.uid,
|
|
requestPaneTab: item ? getDefaultRequestPaneTab(item) : undefined,
|
|
type: 'request'
|
|
}));
|
|
}
|
|
};
|
|
|
|
const prevItemCountRef = useRef(httpItemCount);
|
|
const isDriftLoadingRef = useRef(false);
|
|
|
|
const loadCollectionDrift = async ({ clear = false } = {}) => {
|
|
if (isDriftLoadingRef.current && !clear) return;
|
|
isDriftLoadingRef.current = true;
|
|
if (clear) setCollectionDrift(null);
|
|
setIsDriftLoading(true);
|
|
try {
|
|
const { ipcRenderer } = window;
|
|
const result = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
|
collectionPath: collection.pathname,
|
|
brunoConfig: collection.brunoConfig
|
|
});
|
|
|
|
if (!result.error) {
|
|
setCollectionDrift(result);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading collection drift:', err);
|
|
} finally {
|
|
isDriftLoadingRef.current = false;
|
|
setIsDriftLoading(false);
|
|
}
|
|
};
|
|
|
|
const checkForUpdates = async ({ sourceUrlOverride } = {}) => {
|
|
const effectiveUrl = (sourceUrlOverride ?? sourceUrl).trim();
|
|
if (!effectiveUrl) {
|
|
setError('Please enter a URL or select a file');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
setFileNotFound(false);
|
|
setSpecDrift(null);
|
|
setRemoteDrift(null);
|
|
|
|
try {
|
|
const { ipcRenderer } = window;
|
|
const result = await ipcRenderer.invoke('renderer:compare-openapi-specs', {
|
|
collectionUid: collection.uid,
|
|
collectionPath: collection.pathname,
|
|
sourceUrl: effectiveUrl,
|
|
environmentContext: {
|
|
activeEnvironmentUid: collection.activeEnvironmentUid,
|
|
environments: collection.environments,
|
|
runtimeVariables: collection.runtimeVariables,
|
|
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
|
}
|
|
});
|
|
|
|
if (result.errorCode === 'SOURCE_FILE_NOT_FOUND') {
|
|
setFileNotFound(true);
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
|
|
setSpecDrift(result);
|
|
if (result.storedSpec) {
|
|
setStoredSpec(result.storedSpec);
|
|
}
|
|
|
|
// Update Redux store so toolbar status stays in sync
|
|
dispatch(setCollectionUpdate({
|
|
collectionUid: collection.uid,
|
|
hasUpdates: result.isValid !== false && result.hasChanges,
|
|
diff: result,
|
|
error: result.isValid === false ? result.error : null
|
|
}));
|
|
|
|
// Fetch remote drift (remote spec vs collection) for collection-centric categorization
|
|
if (result.newSpec) {
|
|
const remoteComparison = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
|
collectionPath: collection.pathname,
|
|
brunoConfig: collection.brunoConfig,
|
|
compareSpec: result.newSpec
|
|
});
|
|
if (remoteComparison.error) {
|
|
console.error('Error computing remote drift:', remoteComparison.error);
|
|
setError(remoteComparison.error);
|
|
} else {
|
|
setRemoteDrift(remoteComparison);
|
|
}
|
|
}
|
|
|
|
// Refresh collection drift (stored spec vs collection) — skip if no stored spec
|
|
if (!result.storedSpecMissing) {
|
|
await loadCollectionDrift({ clear: true });
|
|
}
|
|
} catch (err) {
|
|
console.error('Error checking for updates:', err);
|
|
setError(formatIpcError(err) || 'Failed to check for updates');
|
|
dispatch(setCollectionUpdate({
|
|
collectionUid: collection.uid,
|
|
hasUpdates: false,
|
|
diff: null,
|
|
error: formatIpcError(err) || 'Failed to check for updates'
|
|
}));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (isConfigured) {
|
|
checkForUpdates();
|
|
}
|
|
}, [isConfigured]);
|
|
|
|
// Reload drift when collection items change (e.g., endpoint deleted from sidebar)
|
|
useEffect(() => {
|
|
if (prevItemCountRef.current !== httpItemCount && isConfigured) {
|
|
prevItemCountRef.current = httpItemCount;
|
|
loadCollectionDrift();
|
|
}
|
|
}, [httpItemCount, isConfigured]);
|
|
|
|
const handleConnect = async () => {
|
|
const trimmedUrl = sourceUrl.trim();
|
|
if (!trimmedUrl) {
|
|
setError('Please enter a URL or select a file');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
setError(null);
|
|
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: trimmedUrl,
|
|
environmentContext: {
|
|
activeEnvironmentUid: collection.activeEnvironmentUid,
|
|
environments: collection.environments,
|
|
runtimeVariables: collection.runtimeVariables,
|
|
globalEnvironmentVariables: collection.globalEnvironmentVariables
|
|
}
|
|
});
|
|
|
|
if (result.isValid === false) {
|
|
setSpecDrift(result);
|
|
setError(result.error);
|
|
return;
|
|
}
|
|
|
|
// Save sync config (no spec file yet — deferred to first sync unless collection already matches)
|
|
await ipcRenderer.invoke('renderer:update-openapi-sync-config', {
|
|
collectionPath: collection.pathname,
|
|
config: {
|
|
sourceUrl: trimmedUrl,
|
|
groupBy: 'tags',
|
|
autoCheck: true,
|
|
autoCheckInterval: 5
|
|
}
|
|
});
|
|
|
|
// Check if collection already matches the spec
|
|
if (result.newSpec) {
|
|
const drift = await ipcRenderer.invoke('renderer:get-collection-drift', {
|
|
collectionPath: collection.pathname,
|
|
brunoConfig: collection.brunoConfig,
|
|
compareSpec: result.newSpec
|
|
});
|
|
|
|
const isInSync = !drift.error
|
|
&& (!drift.missing || drift.missing.length === 0)
|
|
&& (!drift.modified || drift.modified.length === 0)
|
|
&& (!drift.localOnly || drift.localOnly.length === 0);
|
|
|
|
if (isInSync) {
|
|
// Collection matches — save spec file silently to complete setup
|
|
await ipcRenderer.invoke('renderer:save-openapi-spec', {
|
|
collectionPath: collection.pathname,
|
|
specContent: result.newSpecContent || JSON.stringify(result.newSpec, null, 2),
|
|
sourceUrl: trimmedUrl
|
|
});
|
|
}
|
|
}
|
|
|
|
toast.success('OpenAPI sync connected');
|
|
} catch (err) {
|
|
console.error('Error connecting OpenAPI sync:', err);
|
|
setError(formatIpcError(err) || 'Failed to connect');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleDisconnect = async () => {
|
|
try {
|
|
const { ipcRenderer } = window;
|
|
await ipcRenderer.invoke('renderer:remove-openapi-sync-config', {
|
|
collectionPath: collection.pathname,
|
|
sourceUrl: openApiSyncConfig?.sourceUrl || sourceUrl,
|
|
deleteSpecFile: true
|
|
});
|
|
setSourceUrl('');
|
|
setSpecDrift(null);
|
|
setCollectionDrift(null);
|
|
setRemoteDrift(null);
|
|
setStoredSpec(null);
|
|
|
|
// Clear Redux state for this collection
|
|
dispatch(clearCollectionState({ collectionUid: collection.uid }));
|
|
|
|
// Close the openapi-spec tab if open (spec file no longer exists)
|
|
const specTab = tabs.find((t) => t.collectionUid === collection.uid && t.type === 'openapi-spec');
|
|
if (specTab) {
|
|
dispatch(closeTabs({ tabUids: [specTab.uid] }));
|
|
}
|
|
|
|
toast.success('OpenAPI sync disconnected');
|
|
} catch (err) {
|
|
console.error('Error disconnecting sync:', err);
|
|
toast.error('Failed to disconnect sync');
|
|
}
|
|
};
|
|
|
|
// Reload drift — passed to useEndpointActions so it can refresh after actions
|
|
const reloadDrift = () => loadCollectionDrift({ clear: true });
|
|
|
|
// 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,
|
|
config: {
|
|
sourceUrl: newUrl,
|
|
autoCheck,
|
|
autoCheckInterval
|
|
}
|
|
});
|
|
setSourceUrl(newUrl);
|
|
setFileNotFound(false);
|
|
toast.success('Settings saved');
|
|
// Re-check with new settings — pass newUrl directly to avoid stale closure
|
|
await checkForUpdates({ sourceUrlOverride: newUrl });
|
|
} catch (err) {
|
|
console.error('Error saving settings:', err);
|
|
toast.error('Failed to save settings');
|
|
}
|
|
};
|
|
|
|
return {
|
|
// State
|
|
sourceUrl, setSourceUrl,
|
|
isLoading,
|
|
error, setError,
|
|
fileNotFound,
|
|
specDrift,
|
|
collectionDrift,
|
|
remoteDrift,
|
|
isDriftLoading,
|
|
storedSpec,
|
|
|
|
// Handlers
|
|
checkForUpdates,
|
|
handleConnect,
|
|
handleDisconnect,
|
|
handleSaveSettings,
|
|
openEndpointInTab,
|
|
reloadDrift
|
|
};
|
|
};
|
|
|
|
export default useOpenAPISync;
|