Files
bruno/packages/bruno-electron/src/ipc/openapi-sync.js
Abhishek S Lal 83ddfc33d2 refactor(OpenAPISyncTab): streamline component logic and enhance user feedback (#7483)
- Removed unused props and improved error handling in OpenAPISyncTab components.
- Updated messaging in CollectionStatusSection and OverviewSection for clarity.
- Enhanced the SpecDiffModal to provide better visual feedback on changes.
- Refactored sync flow logic to ensure accurate endpoint categorization and improved performance.
- Added new utility functions for better handling of spec changes and endpoint comparisons.
2026-03-14 01:15:10 +05:30

1715 lines
67 KiB
JavaScript

const _ = require('lodash');
const fs = require('fs');
const fsExtra = require('fs-extra');
const path = require('path');
const crypto = require('crypto');
const { ipcMain, app } = require('electron');
const {
parseRequest,
stringifyRequestViaWorker,
parseCollection,
stringifyCollection,
stringifyFolder
} = require('@usebruno/filestore');
const { openApiToBruno } = require('@usebruno/converters');
const { writeFile, sanitizeName, getCollectionFormat } = require('../utils/filesystem');
const { getEnvVars } = require('../utils/collection');
const { getProcessEnvVars } = require('../store/process-env');
const { getCertsAndProxyConfig } = require('./network/cert-utils');
const { makeAxiosInstance } = require('./network/axios-instance');
const jsyaml = require('js-yaml');
/**
* Detect if a string content is YAML (not JSON).
* Attempts JSON.parse first for a definitive check rather than relying on heuristics.
*/
const isYamlContent = (content) => {
if (!content || typeof content !== 'string') return false;
try {
JSON.parse(content);
return false; // Valid JSON — not YAML
} catch {
// Not JSON — verify it's actually parseable as YAML and produces an object
try {
const result = jsyaml.load(content);
return result && typeof result === 'object';
} catch {
return false;
}
}
};
/**
* 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.
*/
const generateSpecHash = (spec) => {
if (!spec) return null;
return crypto.createHash('md5').update(JSON.stringify(spec)).digest('hex');
};
/**
* Validate that a target path is inside the collection directory.
* Prevents path traversal attacks via ../../ in user-supplied paths.
*/
const isPathInsideCollection = (targetPath, collectionPath) => {
const resolvedTarget = path.resolve(targetPath);
const resolvedCollection = path.resolve(collectionPath);
return resolvedTarget.startsWith(resolvedCollection + path.sep) || resolvedTarget === resolvedCollection;
};
/**
* Validate that a URL uses http or https scheme only.
*/
const isValidHttpUrl = (urlString) => {
try {
const url = new URL(urlString);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
};
const isLocalFilePath = (str) => !isValidHttpUrl(str) && typeof str === 'string' && str.length > 0;
/**
* Get the directory where OpenAPI spec files are stored in AppData.
*/
const getSpecsDir = () => path.join(app.getPath('userData'), 'specs');
/**
* Load the spec metadata file from AppData.
* Returns an object mapping collectionPath → array of { filename, sourceUrl } entries.
*/
const loadSpecMetadata = () => {
const metadataPath = path.join(getSpecsDir(), 'metadata.json');
try {
if (fs.existsSync(metadataPath)) {
return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
}
} catch {
// ignore parse errors, return empty
}
return {};
};
/**
* Save the spec metadata file to AppData.
*/
const saveSpecMetadata = (metadata) => {
const specsDir = getSpecsDir();
fsExtra.ensureDirSync(specsDir);
const metadataPath = path.join(specsDir, 'metadata.json');
const tmpPath = metadataPath + '.tmp';
fs.writeFileSync(tmpPath, JSON.stringify(metadata, null, 2), 'utf8');
fs.renameSync(tmpPath, metadataPath);
};
/**
* Get all spec entries for a collection.
*/
const getSpecEntriesForCollection = (collectionPath) => {
return loadSpecMetadata()[collectionPath] || [];
};
/**
* Get the spec entry for a specific sourceUrl within a collection.
*/
const getSpecEntryForUrl = (collectionPath, sourceUrl) => {
return getSpecEntriesForCollection(collectionPath).find((e) => e.sourceUrl === sourceUrl) || null;
};
/**
* Parse a spec string (JSON or YAML) into an object.
*/
const parseSpec = (content) => {
try {
return JSON.parse(content);
} catch {
return jsyaml.load(content);
}
};
/**
* Validate that a parsed spec object is a valid OpenAPI 3.x document.
* Swagger 2.0 is not supported — the converter only handles OpenAPI 3.x.
*/
const isValidOpenApiSpec = (spec) => {
if (!spec || typeof spec !== 'object') return false;
if (spec.swagger) return false;
if (spec.openapi && typeof spec.openapi === 'string' && spec.openapi.startsWith('3.')) {
return spec.paths && typeof spec.paths === 'object';
}
return false;
};
/**
* Fetch OpenAPI spec content from a remote URL or local file path.
* Handles proxy/cert resolution for remote URLs.
* Returns { content, spec } on success, or { error, errorCode? } on failure.
*/
const fetchSpecFromSource = async ({ collectionUid, collectionPath, sourceUrl, environmentContext = {} }) => {
const { activeEnvironmentUid, environments = [], runtimeVariables = {}, globalEnvironmentVariables = {} } = environmentContext;
if (!isValidHttpUrl(sourceUrl) && !isLocalFilePath(sourceUrl)) {
return { error: 'Invalid source: only http/https URLs and local file paths are allowed' };
}
let content;
if (isLocalFilePath(sourceUrl)) {
const resolvedPath = collectionPath ? path.resolve(collectionPath, sourceUrl) : sourceUrl;
if (!fs.existsSync(resolvedPath)) {
return { error: `Spec file not found at: ${sourceUrl}`, errorCode: 'SOURCE_FILE_NOT_FOUND' };
}
content = fs.readFileSync(resolvedPath, 'utf8');
} else {
const cacheBustUrl = sourceUrl.includes('?')
? `${sourceUrl}&_=${Date.now()}`
: `${sourceUrl}?_=${Date.now()}`;
const environment = _.find(environments, (e) => e.uid === activeEnvironmentUid);
const envVars = getEnvVars(environment);
const processEnvVars = getProcessEnvVars(collectionUid);
const { proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions } = await getCertsAndProxyConfig({
collectionUid,
collection: { promptVariables: {} },
request: {},
envVars,
runtimeVariables,
processEnvVars,
collectionPath,
globalEnvironmentVariables
});
const axiosInstance = makeAxiosInstance({ proxyMode, proxyConfig, httpsAgentRequestFields, interpolationOptions });
try {
const response = await axiosInstance.get(cacheBustUrl, {
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
},
timeout: 30000,
transformResponse: [(data) => data]
});
content = response.data;
} catch (fetchErr) {
if (fetchErr.response) {
return { error: `Failed to fetch spec: ${fetchErr.response.status} ${fetchErr.response.statusText}` };
}
const reason = fetchErr.code || fetchErr.cause?.code || fetchErr.name || 'unknown';
return { error: `Could not reach ${sourceUrl} (${reason})` };
}
}
const spec = parseSpec(content);
return { content, spec };
};
/**
* Normalize a Bruno request URL down to a comparable path.
* Strips template variables ({{baseUrl}}), protocol/host, query params,
* converts {param} to :param, collapses slashes, removes trailing slash.
*/
const normalizeUrlPath = (urlStr) => {
if (!urlStr) return '';
return urlStr
.replace(/\{\{[^}]+\}\}/g, '')
.replace(/^https?:\/\/[^/]+/, '')
.replace(/\?.*$/, '')
.replace(/{([^}]+)}/g, ':$1')
.replace(/\/+/g, '/')
.replace(/\/$/, '');
};
/**
* Load bruno config from disk. Returns { format, brunoConfig, collectionRoot }.
* collectionRoot is only set for yml format collections.
*/
const loadBrunoConfig = (collectionPath) => {
const format = getCollectionFormat(collectionPath);
let brunoConfig;
let collectionRoot;
if (format === 'yml') {
const configFilePath = path.join(collectionPath, 'opencollection.yml');
if (!fs.existsSync(configFilePath)) {
throw new Error('opencollection.yml not found');
}
const content = fs.readFileSync(configFilePath, 'utf8');
const parsed = parseCollection(content, { format });
brunoConfig = parsed.brunoConfig;
collectionRoot = parsed.collectionRoot;
} else {
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
if (!fs.existsSync(brunoJsonPath)) {
throw new Error('bruno.json not found');
}
brunoConfig = JSON.parse(fs.readFileSync(brunoJsonPath, 'utf8'));
}
return { format, brunoConfig, collectionRoot };
};
/**
* Save bruno config to disk (bruno.json or opencollection.yml).
*/
const saveBrunoConfig = async (collectionPath, format, brunoConfig, collectionRoot) => {
if (format === 'yml') {
const content = await stringifyCollection(collectionRoot, brunoConfig, { format });
await writeFile(path.join(collectionPath, 'opencollection.yml'), content);
} else {
const brunoJsonPath = path.join(collectionPath, 'bruno.json');
await writeFile(brunoJsonPath, JSON.stringify(brunoConfig, null, 2));
}
};
/**
* Find a spec item in a Bruno collection tree by HTTP method and path.
* Returns { item, folderName } or null.
*/
const findItemInCollection = (items, method, targetPath, currentFolderName = null) => {
const normalizedTarget = normalizeUrlPath(targetPath);
for (const item of items) {
if (item.type === 'folder' && item.items) {
const found = findItemInCollection(item.items, method, targetPath, item.name);
if (found) return found;
}
if (item.request?.method?.toLowerCase() === method.toLowerCase()) {
if (normalizeUrlPath(item.request.url) === normalizedTarget) {
return { item, folderName: currentFolderName };
}
}
}
return null;
};
/**
* Find an existing request file on disk by HTTP method and normalized path.
* Scans .bru/.yml files in the collection directory recursively.
* Returns { filePath, request, content, fileFormat } or null.
*/
const findRequestFileOnDisk = (dirPath, method, urlPath) => {
if (!fs.existsSync(dirPath)) return null;
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) {
const found = findRequestFileOnDisk(filePath, method, urlPath);
if (found) return found;
} else if (file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml')) {
if (file.startsWith('folder.') || file.startsWith('collection.')) continue;
try {
const content = fs.readFileSync(filePath, 'utf8');
const fileFormat = file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru';
const request = parseRequest(content, { format: fileFormat });
if (request?.request) {
const reqMethod = request.request.method?.toUpperCase();
const reqPath = normalizeUrlPath(request.request.url);
if (reqMethod === method && reqPath === urlPath) {
return { filePath, request, content, fileFormat };
}
}
} catch (err) {
// Skip files that can't be parsed
}
}
}
return null;
};
/**
* Save an OpenAPI spec file to AppData specs directory.
* - Detects format (JSON/YAML) from the content and uses the correct file extension.
* - Reuses an existing UUID filename if one exists for this sourceUrl, otherwise creates a new one.
* - Updates metadata.json with the filename → sourceUrl mapping.
*
* @param {Object} params
* @param {string} params.collectionPath - Path to the collection directory.
* @param {string} params.content - The spec content string to save (JSON or YAML).
* @param {string} params.sourceUrl - The source URL identifying which spec entry to update.
*/
const saveOpenApiSpecFile = async ({ collectionPath, content, sourceUrl }) => {
const specsDir = getSpecsDir();
await fsExtra.ensureDir(specsDir);
const meta = loadSpecMetadata();
const entries = meta[collectionPath] || [];
const existingEntry = entries.find((e) => e.sourceUrl === sourceUrl);
let filename;
if (existingEntry) {
// Reuse existing UUID filename
filename = existingEntry.filename;
} else {
// Generate a new UUID filename based on content type
const ext = isYamlContent(content) ? 'yaml' : 'json';
filename = `${crypto.randomUUID()}.${ext}`;
meta[collectionPath] = [...entries, { filename, sourceUrl }];
saveSpecMetadata(meta);
}
await writeFile(path.join(specsDir, filename), content);
};
/**
* Save an OpenAPI spec file and update sync metadata (lastSyncDate, specHash) in brunoConfig.
* Shared by both the IPC handler (connect flow) and the import flow.
*/
const saveSpecAndUpdateMetadata = async ({ collectionPath, specContent, sourceUrl }) => {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
let parsedSpec;
try {
parsedSpec = JSON.parse(specContent);
} catch {
parsedSpec = jsyaml.load(specContent);
}
const specHash = generateSpecHash(parsedSpec);
const lastSyncDate = new Date().toISOString();
const openapi = brunoConfig.openapi || [];
const idx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
if (idx !== -1) {
openapi[idx] = { ...openapi[idx], lastSyncDate, specHash };
} else {
openapi.push({ sourceUrl, lastSyncDate, specHash });
}
brunoConfig.openapi = openapi;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
};
/**
* Clean up stored spec files and metadata for a collection (called when a collection is removed).
*/
const cleanupSpecFilesForCollection = (collectionPath) => {
const meta = loadSpecMetadata();
const entries = meta[collectionPath] || [];
for (const entry of entries) {
const specPath = path.join(getSpecsDir(), entry.filename);
if (fs.existsSync(specPath)) fs.unlinkSync(specPath);
}
if (entries.length > 0) {
delete meta[collectionPath];
saveSpecMetadata(meta);
}
};
/**
* Merge spec params/headers with existing user values.
* 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 && e.value === specItem.value
);
return existing ? { ...specItem, enabled: existing.enabled } : specItem;
});
};
/**
* Merge a spec item into an existing request, preserving collection-specific data
* (tests, scripts, assertions) and user values for matching params/headers.
*
* fullReset: true = spec replaces entire request section (reset mode)
* false = only override url/body/auth from spec (sync mode)
*/
const mergeSpecIntoRequest = (existingRequest, specItem, { fullReset = false } = {}) => {
const mergedParams = mergeWithUserValues(specItem.request.params, existingRequest.request?.params);
const mergedHeaders = mergeWithUserValues(specItem.request.headers, existingRequest.request?.headers);
if (fullReset) {
return {
...existingRequest,
request: {
...specItem.request,
params: mergedParams || [],
headers: mergedHeaders || []
}
};
}
return {
...existingRequest,
request: {
...existingRequest.request,
url: specItem.request.url,
body: specItem.request.body,
auth: specItem.request.auth,
params: mergedParams || existingRequest.request?.params || [],
headers: mergedHeaders || existingRequest.request?.headers || []
}
};
};
/**
* 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 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}`);
return collectionPath;
}
if (!fs.existsSync(targetFolder)) {
fs.mkdirSync(targetFolder, { recursive: true });
const folderBruPath = path.join(targetFolder, `folder.${format}`);
const folderContent = await stringifyFolder({ meta: { name: safeFolderName } }, { format });
await writeFile(folderBruPath, folderContent);
}
return targetFolder;
};
/**
* Flatten a Bruno collection's items into a Map keyed by endpoint ID (METHOD:normalizedPath).
* Each value includes the original item plus the parent folderName.
*/
const buildSpecItemsMap = (collectionItems) => {
const map = new Map();
const flatten = (items, parentFolder = null) => {
for (const item of items) {
if (item.type === 'folder' && item.items) {
flatten(item.items, item.name);
} else if (item.request) {
const method = item.request.method?.toUpperCase() || 'GET';
const urlPath = normalizeUrlPath(item.request.url);
const id = `${method}:${urlPath}`;
map.set(id, { ...item, folderName: parentFolder });
}
}
};
flatten(collectionItems);
return map;
};
/**
* Recursively extracts all key paths from a parsed JSON value (dot-notation).
* Used to compare JSON body structure/schema without comparing values.
*/
const extractJsonKeys = (obj, prefix = '') => {
const keys = [];
if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
keys.push(fullKey);
keys.push(...extractJsonKeys(obj[key], fullKey));
}
} else if (Array.isArray(obj) && obj.length > 0) {
// Only inspect first element (spec arrays always have one template item)
keys.push(...extractJsonKeys(obj[0], `${prefix}[]`));
}
return keys;
};
/**
* Compare two Bruno-format requests field-by-field.
* Returns { hasDiff, changes } where changes is an array of human-readable strings.
*/
const compareRequestFields = (specRequest, actualRequest) => {
// Compare parameters by name:type pairs (catches query<->path type changes)
const specParamKeys = (specRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
const actualParamKeys = (actualRequest.params || []).map((p) => `${p.name}:${p.type || 'query'}`).sort();
// Compare headers (by name)
const specHeaderNames = (specRequest.headers || []).map((h) => h.name).sort();
const actualHeaderNames = (actualRequest.headers || []).map((h) => h.name).sort();
// Check for differences
const paramsDiff = JSON.stringify(specParamKeys) !== JSON.stringify(actualParamKeys);
const headersDiff = JSON.stringify(specHeaderNames) !== JSON.stringify(actualHeaderNames);
// Check body mode difference
const specBodyMode = specRequest.body?.mode || 'none';
const actualBodyMode = actualRequest.body?.mode || 'none';
const bodyDiff = specBodyMode !== actualBodyMode;
// Check auth mode difference
const specAuthMode = specRequest.auth?.mode || 'none';
const actualAuthMode = actualRequest.auth?.mode || 'none';
const authDiff = specAuthMode !== actualAuthMode;
// Check auth config differences when auth modes match
let authConfigDiff = false;
if (!authDiff && specAuthMode !== 'none' && specAuthMode !== 'inherit') {
if (specAuthMode === 'apikey') {
const specApikey = specRequest.auth?.apikey || {};
const actualApikey = actualRequest.auth?.apikey || {};
authConfigDiff = specApikey.key !== actualApikey.key || specApikey.placement !== actualApikey.placement;
} else if (specAuthMode === 'oauth2') {
const specOauth2 = specRequest.auth?.oauth2 || {};
const actualOauth2 = actualRequest.auth?.oauth2 || {};
const grantType = specOauth2.grantType || actualOauth2.grantType;
const commonFields = ['grantType', 'scope'];
const grantTypeFields = {
authorization_code: [...commonFields, 'authorizationUrl', 'accessTokenUrl'],
implicit: [...commonFields, 'authorizationUrl'],
password: [...commonFields, 'accessTokenUrl'],
client_credentials: [...commonFields, 'accessTokenUrl']
};
const fields = grantTypeFields[grantType] || commonFields;
authConfigDiff = fields.some((field) => specOauth2[field] !== actualOauth2[field]);
}
}
// Check form field names when body modes match and mode is form-based
let formFieldsDiff = false;
let specFormFieldNames = [];
let actualFormFieldNames = [];
if (!bodyDiff && (specBodyMode === 'formUrlEncoded' || specBodyMode === 'multipartForm')) {
if (specBodyMode === 'multipartForm') {
specFormFieldNames = (specRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
actualFormFieldNames = (actualRequest.body?.multipartForm || []).map((f) => `${f.name}:${f.type || 'text'}`).sort();
} else {
specFormFieldNames = (specRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
actualFormFieldNames = (actualRequest.body?.formUrlEncoded || []).map((f) => f.name).sort();
}
formFieldsDiff = JSON.stringify(specFormFieldNames) !== JSON.stringify(actualFormFieldNames);
}
// Check JSON body structure when both sides use json mode
let jsonBodyDiff = false;
if (!bodyDiff && specBodyMode === 'json') {
try {
const specJson = specRequest.body?.json ? JSON.parse(specRequest.body.json) : null;
const actualJson = actualRequest.body?.json ? JSON.parse(actualRequest.body.json) : null;
if (specJson !== null && actualJson !== null) {
const specKeys = extractJsonKeys(specJson).sort();
const actualKeys = extractJsonKeys(actualJson).sort();
jsonBodyDiff = JSON.stringify(specKeys) !== JSON.stringify(actualKeys);
} else if ((specJson === null) !== (actualJson === null)) {
jsonBodyDiff = true;
}
} catch (e) {
// Malformed JSON — skip structural comparison
}
}
const hasDiff = paramsDiff || headersDiff || bodyDiff || authDiff || authConfigDiff || formFieldsDiff || jsonBodyDiff;
const changes = [];
if (hasDiff) {
if (paramsDiff) {
const addedParams = actualParamKeys.filter((p) => !specParamKeys.includes(p));
const removedParams = specParamKeys.filter((p) => !actualParamKeys.includes(p));
if (addedParams.length) changes.push(`+${addedParams.length} params`);
if (removedParams.length) changes.push(`-${removedParams.length} params`);
}
if (headersDiff) {
const addedHeaders = actualHeaderNames.filter((h) => !specHeaderNames.includes(h));
const removedHeaders = specHeaderNames.filter((h) => !actualHeaderNames.includes(h));
if (addedHeaders.length) changes.push(`+${addedHeaders.length} headers`);
if (removedHeaders.length) changes.push(`-${removedHeaders.length} headers`);
}
if (bodyDiff) changes.push(`body: ${actualBodyMode}`);
if (authDiff) changes.push(`auth: ${actualAuthMode}`);
if (authConfigDiff) changes.push('auth config');
if (formFieldsDiff) {
const addedFields = actualFormFieldNames.filter((f) => !specFormFieldNames.includes(f));
const removedFields = specFormFieldNames.filter((f) => !actualFormFieldNames.includes(f));
if (addedFields.length) changes.push(`+${addedFields.length} form fields`);
if (removedFields.length) changes.push(`-${removedFields.length} form fields`);
}
if (jsonBodyDiff) changes.push('body schema');
}
return { hasDiff, changes };
};
/**
* Load the stored spec for a collection and convert it to Bruno collection format.
* Throws if no stored spec file exists.
*/
const loadStoredSpecCollection = (collectionPath, brunoConfig) => {
const sourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const specEntry = sourceUrl ? getSpecEntryForUrl(collectionPath, sourceUrl) : null;
const specPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
if (!specPath || !fs.existsSync(specPath)) {
throw new Error('No stored spec file found. Please sync with remote spec first.');
}
const specRaw = fs.readFileSync(specPath, 'utf8');
const storedSpec = parseSpec(specRaw);
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
return openApiToBruno(storedSpec, { groupBy });
};
const registerOpenAPISyncIpc = (mainWindow) => {
ipcMain.handle('renderer:check-openapi-updates', async (event, {
collectionUid, collectionPath, sourceUrl, storedSpecHash, environmentContext
}) => {
try {
const result = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext });
if (result.error) {
return { hasUpdates: false, error: result.error, errorCode: result.errorCode };
}
const remoteSpecHash = generateSpecHash(result.spec);
return { hasUpdates: storedSpecHash !== remoteSpecHash, remoteSpecHash };
} catch (error) {
console.error('[OpenAPI Sync] Lightweight check error:', error.message);
return { hasUpdates: false, error: error.message };
}
});
ipcMain.handle('renderer:compare-openapi-specs', async (event, {
collectionUid, collectionPath, sourceUrl, environmentContext
}) => {
try {
// Compare two OpenAPI specs by converting both to Bruno format and using field-level comparison.
// This ensures specDrift uses the same comparison sensitivity as collectionDrift/remoteDrift.
const compareSpecs = (oldSpec, newSpec, groupBy) => {
// Convert both specs to Bruno collection format
const oldBruno = oldSpec ? openApiToBruno(oldSpec, { groupBy }) : { items: [] };
const newBruno = newSpec ? openApiToBruno(newSpec, { groupBy }) : { items: [] };
// Build endpoint maps keyed by METHOD:normalizedPath
const oldItems = buildSpecItemsMap(oldBruno.items || []);
const newItems = buildSpecItemsMap(newBruno.items || []);
const added = [];
const removed = [];
const modified = [];
const unchanged = [];
for (const [id, newItem] of newItems) {
const colonIndex = id.indexOf(':');
const method = id.substring(0, colonIndex);
const urlPath = id.substring(colonIndex + 1);
if (!oldItems.has(id)) {
added.push({ id, method, path: urlPath, name: newItem.name });
} else {
const oldItem = oldItems.get(id);
const { hasDiff, changes } = compareRequestFields(oldItem.request, newItem.request);
if (hasDiff) {
modified.push({ id, method, path: urlPath, name: newItem.name, changes: changes.join(', ') });
} else {
unchanged.push({ id, method, path: urlPath, name: newItem.name });
}
}
}
for (const [id] of oldItems) {
if (!newItems.has(id)) {
const colonIndex = id.indexOf(':');
const method = id.substring(0, colonIndex);
const urlPath = id.substring(colonIndex + 1);
const oldItem = oldItems.get(id);
removed.push({ id, method, path: urlPath, name: oldItem.name });
}
}
// Compare metadata (title, version, description)
const oldTitle = oldSpec?.info?.title || null;
const newTitle = newSpec?.info?.title || null;
const titleChanged = oldTitle !== newTitle;
const oldVersion = oldSpec?.info?.version || null;
const newVersion = newSpec?.info?.version || null;
const versionChanged = oldVersion !== newVersion;
const oldDescription = oldSpec?.info?.description || null;
const newDescription = newSpec?.info?.description || null;
const descriptionChanged = oldDescription !== newDescription;
const metadataChanged = titleChanged || versionChanged || descriptionChanged;
return {
added,
removed,
modified,
unchanged,
// Metadata changes
titleChanged,
storedTitle: oldTitle,
newTitle,
versionChanged,
storedVersion: oldVersion,
newVersion,
descriptionChanged,
storedDescription: oldDescription,
newDescription,
metadataChanged,
hasChanges: added.length > 0 || removed.length > 0 || modified.length > 0 || metadataChanged
};
};
const specEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
const storedSpecPath = specEntry ? path.join(getSpecsDir(), specEntry.filename) : null;
let storedSpec = null;
let storedContent = '';
const storedSpecMissing = !storedSpecPath || !fs.existsSync(storedSpecPath);
if (!storedSpecMissing) {
storedContent = fs.readFileSync(storedSpecPath, 'utf8');
storedSpec = parseSpec(storedContent);
}
const fetchResult = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext });
if (fetchResult.error) {
return {
isValid: false,
error: fetchResult.error,
errorCode: fetchResult.errorCode,
storedSpec,
storedSpecMissing
};
}
const newSpecContent = fetchResult.content;
const newSpec = fetchResult.spec;
if (!isValidOpenApiSpec(newSpec)) {
const error = newSpec?.swagger
? 'Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.'
: 'The source does not contain a valid OpenAPI 3.x specification';
return {
isValid: false,
error,
added: [],
removed: [],
unchanged: [],
hasChanges: false
};
}
// Check for title/name changes
const storedTitle = storedSpec?.info?.title || null;
const newTitle = newSpec?.info?.title || null;
const titleChanged = storedSpec && storedTitle && newTitle && storedTitle !== newTitle;
// Generate hashes for quick change detection
const storedSpecHash = generateSpecHash(storedSpec);
const remoteSpecHash = generateSpecHash(newSpec);
const hasRemoteChanges = storedSpecHash !== remoteSpecHash;
// Read groupBy from brunoConfig for consistent spec conversion
let groupBy = 'tags';
try {
const { brunoConfig } = loadBrunoConfig(collectionPath);
groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
} catch (e) {
// Default to 'tags' if brunoConfig is not available
}
const diff = compareSpecs(storedSpec, newSpec, groupBy);
// Detect remote spec format and determine correct filename
const remoteIsYaml = isYamlContent(newSpecContent);
const correctSpecFilename = remoteIsYaml ? 'openapi.yaml' : 'openapi.json';
// Generate unified diff for text diff view
const { createTwoFilesPatch } = require('diff');
const prettyStored = prettyPrintSpec(storedContent);
const prettyNew = prettyPrintSpec(newSpecContent);
const totalLines = Math.max(
prettyStored.split('\n').length,
prettyNew.split('\n').length
);
const unifiedDiff = createTwoFilesPatch(
correctSpecFilename, correctSpecFilename,
prettyStored, prettyNew,
'Current Spec', 'New Spec',
{ context: totalLines }
);
return {
...diff,
isValid: true,
storedSpec,
newSpec,
newSpecContent,
specFilename: correctSpecFilename,
// Hash comparison for quick change detection
hasRemoteChanges,
storedSpecHash,
remoteSpecHash,
storedSpecMissing,
// Metadata
titleChanged,
storedTitle,
newTitle,
// Text diff
unifiedDiff
};
} catch (error) {
console.error('Error comparing OpenAPI specs:', error);
throw error;
}
});
// Collection Drift Detection - compare stored spec (converted to bru) vs actual .bru files
ipcMain.handle('renderer:get-collection-drift', async (event, { collectionPath, brunoConfig: passedBrunoConfig, compareSpec }) => {
try {
// Use passed brunoConfig if available, otherwise read from disk
let brunoConfig;
if (passedBrunoConfig) {
brunoConfig = passedBrunoConfig;
} else {
try {
({ brunoConfig } = loadBrunoConfig(collectionPath));
} catch (err) {
return { error: err.message };
}
}
// Load spec to compare against — use compareSpec if provided, otherwise read stored spec from disk
let specToCompare;
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
if (compareSpec) {
specToCompare = compareSpec;
} else {
const driftSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const driftSpecEntry = driftSourceUrl ? getSpecEntryForUrl(collectionPath, driftSourceUrl) : null;
const storedSpecPath = driftSpecEntry ? path.join(getSpecsDir(), driftSpecEntry.filename) : null;
if (!storedSpecPath || !fs.existsSync(storedSpecPath)) {
return {
error: null,
noStoredSpec: true,
inSync: [],
modified: [],
localOnly: [],
missing: [],
specEndpointCount: 0,
collectionEndpointCount: 0
};
}
const storedContent = fs.readFileSync(storedSpecPath, 'utf8');
specToCompare = parseSpec(storedContent);
}
// Convert spec to Bruno collection format
const specAsCollection = openApiToBruno(specToCompare, { groupBy });
// Build map of expected items by endpoint ID (method:path)
const specItems = buildSpecItemsMap(specAsCollection.items || []);
// Scan and parse collection endpoints from disk
const scanCollectionFiles = (dirPath, relativePath = '') => {
const files = [];
if (!fs.existsSync(dirPath)) return files;
const entries = fs.readdirSync(dirPath);
for (const entry of entries) {
const fullPath = path.join(dirPath, entry);
const relPath = relativePath ? path.join(relativePath, entry) : entry;
if (['node_modules', '.git', 'environments'].includes(entry)) continue;
const stats = fs.statSync(fullPath);
if (stats.isDirectory()) {
files.push(...scanCollectionFiles(fullPath, relPath));
} else if ((entry.endsWith('.bru') || entry.endsWith('.yml') || entry.endsWith('.yaml'))
&& !entry.startsWith('folder.') && !entry.startsWith('collection.') && !entry.startsWith('opencollection.')) {
files.push({ fullPath, relativePath: relPath });
}
}
return files;
};
const collectionFiles = scanCollectionFiles(collectionPath);
const collectionEndpoints = [];
for (const { fullPath, relativePath } of collectionFiles) {
try {
const content = fs.readFileSync(fullPath, 'utf8');
const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
const parsed = parseRequest(content, { format: fileFormat });
if (!parsed?.request) continue;
collectionEndpoints.push({
fullPath,
relativePath,
request: parsed.request,
name: parsed.meta?.name || parsed.name || path.basename(fullPath)
});
} catch (err) {
console.error(`[Collection Drift] Error parsing ${fullPath}:`, err.message);
}
}
// Compare each collection endpoint against spec
const result = {
inSync: [],
modified: [],
localOnly: [],
missing: []
};
const foundEndpointIds = new Set();
for (const { fullPath, relativePath, request: actualRequest, name: itemName } of collectionEndpoints) {
const method = actualRequest.method?.toUpperCase() || 'GET';
const urlPath = normalizeUrlPath(actualRequest.url);
const id = `${method}:${urlPath}`;
foundEndpointIds.add(id);
const specItem = specItems.get(id);
if (!specItem) {
// Endpoint exists in collection but not in spec
result.localOnly.push({
id,
method,
path: urlPath,
filePath: relativePath,
pathname: fullPath,
name: itemName
});
} else {
// Compare key fields to detect drift
const { hasDiff, changes } = compareRequestFields(specItem.request, actualRequest);
if (hasDiff) {
result.modified.push({
id,
method,
path: urlPath,
filePath: relativePath,
pathname: fullPath,
name: itemName,
changes: changes.join(', '),
actualRequest: { request: actualRequest },
specItem
});
} else {
result.inSync.push({
id,
method,
path: urlPath,
filePath: relativePath,
pathname: fullPath,
name: itemName
});
}
}
}
// Find endpoints in spec but missing from collection
for (const [id, specItem] of specItems) {
if (!foundEndpointIds.has(id)) {
// Split only on first colon to preserve :param in paths
const colonIndex = id.indexOf(':');
const method = id.substring(0, colonIndex);
const urlPath = id.substring(colonIndex + 1);
result.missing.push({
id,
method,
path: urlPath,
name: specItem.name || specItem.request?.url || id
});
}
}
return {
error: null,
noStoredSpec: false,
...result,
specEndpointCount: specItems.size,
collectionEndpointCount: collectionEndpoints.length
};
} catch (error) {
console.error('Error getting collection drift:', error);
throw error;
}
});
// Get endpoint diff data for visual comparison (spec vs collection)
ipcMain.handle('renderer:get-endpoint-diff-data', async (event, { collectionPath, endpointId, newSpec }) => {
try {
let brunoConfig;
try {
({ brunoConfig } = loadBrunoConfig(collectionPath));
} catch (err) {
return { error: err.message };
}
// Parse endpoint ID (format: "METHOD:path")
const [method, ...pathParts] = endpointId.split(':');
const endpointPath = pathParts.join(':'); // Rejoin in case path contains ':'
// Get spec to use (new spec if provided, otherwise stored spec)
let specToUse = newSpec;
if (!specToUse) {
const diffSourceUrl = brunoConfig?.openapi?.[0]?.sourceUrl;
const diffSpecEntry = diffSourceUrl ? getSpecEntryForUrl(collectionPath, diffSourceUrl) : null;
const storedSpecPath = diffSpecEntry ? path.join(getSpecsDir(), diffSpecEntry.filename) : null;
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
const content = fs.readFileSync(storedSpecPath, 'utf8');
specToUse = parseSpec(content);
}
}
if (!specToUse) {
return { error: 'No spec available' };
}
// Convert spec to Bruno collection format
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
const specAsCollection = openApiToBruno(specToUse, { groupBy });
// Find the spec item for this endpoint
const specItem = findItemInCollection(specAsCollection.items || [], method, endpointPath)?.item || null;
// Find the actual collection file for this endpoint
const actualFile = findRequestFileOnDisk(collectionPath, method.toUpperCase(), endpointPath);
const actualRequest = actualFile?.request || null;
// Transform to visual diff format (matching what VisualDiffViewer rendering components expect)
// Components like VisualDiffUrlBar, VisualDiffParams, etc. read from data.request.*
const transformToVisualFormat = (item) => {
if (!item) return null;
const req = item.request || item;
// Strip query string from URL - params are shown in the separate Parameters section
const urlWithoutQuery = (req.url || '').split('?')[0];
// Normalize params/headers to only include fields relevant for comparison.
// Different sources (openApiToBruno vs parseRequest) include different metadata
// fields (uid, description) which cause false positives in isEqual comparisons.
const normalizeParams = (params) => (params || []).map((p) => ({
name: p.name,
value: p.value,
enabled: p.enabled !== false,
type: p.type
}));
const normalizeHeaders = (headers) => (headers || []).map((h) => ({
name: h.name,
value: h.value,
enabled: h.enabled !== false
}));
return {
name: item.name || item.meta?.name,
type: item.type,
request: {
method: req.method,
url: urlWithoutQuery,
params: normalizeParams(req.params),
headers: normalizeHeaders(req.headers),
body: req.body || {},
auth: req.auth || {},
vars: item.vars || req.vars || {},
assertions: item.assertions || req.assertions || [],
script: item.script || req.script || {},
tests: item.tests || req.tests || '',
docs: item.docs || req.docs || ''
}
};
};
return {
error: null,
// oldData = current collection state, newData = expected from spec
oldData: transformToVisualFormat(actualRequest),
newData: transformToVisualFormat(specItem)
};
} catch (error) {
console.error('Error getting endpoint diff data:', error);
return { error: error.message };
}
});
// Sync modes: 'spec-only' | 'reset' | 'sync' (default)
ipcMain.handle('renderer:apply-openapi-sync', async (event, { collectionPath, sourceUrl, addNewRequests, removeDeletedRequests, diff, localOnlyToRemove = [], driftedToReset = [], mode = 'sync', endpointDecisions = {} }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
// Mode: spec-only - Just save the spec, don't touch collection
if (mode === 'spec-only') {
if (diff.newSpec && typeof diff.newSpec === 'object') {
const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2);
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
}
// Update sync metadata
const openapi = brunoConfig.openapi || [];
const specOnlyIdx = openapi.findIndex((e) => e.sourceUrl === sourceUrl);
if (specOnlyIdx !== -1) {
openapi[specOnlyIdx] = {
...openapi[specOnlyIdx],
lastSyncDate: new Date().toISOString(),
specHash: generateSpecHash(diff.newSpec)
};
}
brunoConfig.openapi = openapi;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
return { success: true, mode: 'spec-only' };
}
// Mode: reset - Save spec and reset all endpoints to spec (preserve tests/scripts)
if (mode === 'reset' && diff.newSpec) {
const openapiEntryReset = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
const groupBy = openapiEntryReset?.groupBy || 'tags';
const newCollection = openApiToBruno(diff.newSpec, { groupBy });
// Build map of spec items by endpoint ID
const specItemsMap = buildSpecItemsMap(newCollection.items || []);
// Find and update existing .bru files
const findAndResetRequest = async (dirPath) => {
if (!fs.existsSync(dirPath)) return;
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) {
await findAndResetRequest(filePath);
} else if ((file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml'))
&& !file.startsWith('folder.') && !file.startsWith('collection.')) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const fileFormat = file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru';
const existingRequest = parseRequest(content, { format: fileFormat });
if (existingRequest?.request) {
const method = existingRequest.request.method?.toUpperCase() || 'GET';
const urlPath = normalizeUrlPath(existingRequest.request.url);
const id = `${method}:${urlPath}`;
const specItem = specItemsMap.get(id);
if (specItem) {
const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true });
const newContent = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat });
await writeFile(filePath, newContent);
// Mark as processed
specItemsMap.delete(id);
}
}
} catch (err) {
console.error(`Error resetting file ${filePath}:`, err);
}
}
}
};
await findAndResetRequest(collectionPath);
// Create missing endpoints from spec
for (const [, specItem] of specItemsMap) {
let targetFolder = collectionPath;
if (specItem.folderName && groupBy === 'tags') {
targetFolder = await ensureTagFolder(collectionPath, specItem.folderName, format);
}
const requestContent = await stringifyRequestViaWorker(specItem, { format });
const sanitizedFilename = `${sanitizeName(specItem.name || path.basename(specItem.filename || '', `.${format}`))}.${format}`;
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
}
// Save spec in original format
const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2);
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
// Update sync metadata
const openapiReset = brunoConfig.openapi || [];
const resetIdx = openapiReset.findIndex((e) => e.sourceUrl === sourceUrl);
if (resetIdx !== -1) {
openapiReset[resetIdx] = {
...openapiReset[resetIdx],
lastSyncDate: new Date().toISOString(),
specHash: generateSpecHash(diff.newSpec)
};
}
brunoConfig.openapi = openapiReset;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
return { success: true, mode: 'reset' };
}
// Mode: sync (default) — compute shared values once
const syncEntry = (brunoConfig.openapi || []).find((e) => e.sourceUrl === sourceUrl);
const groupBy = syncEntry?.groupBy || 'tags';
let newCollection;
if (diff.newSpec) {
try {
newCollection = openApiToBruno(diff.newSpec, { groupBy });
} catch (err) {
console.error('[OpenAPI Sync] Error converting spec:', err);
}
}
// Remove endpoints before adding new ones to avoid filename collisions
// (e.g., when a path is renamed but the summary stays the same, both generate the same filename)
if (removeDeletedRequests && diff.removed?.length > 0) {
const findAndRemoveRequest = (dirPath) => {
if (!fs.existsSync(dirPath)) return;
const files = fs.readdirSync(dirPath);
for (const file of files) {
const filePath = path.join(dirPath, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory() && !['node_modules', '.git', 'environments'].includes(file)) {
findAndRemoveRequest(filePath);
} else if ((file.endsWith('.bru') || file.endsWith('.yml') || file.endsWith('.yaml'))
&& !file.startsWith('folder.') && !file.startsWith('collection.')) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const request = parseRequest(content, { format: file.endsWith('.yml') || file.endsWith('.yaml') ? 'yml' : 'bru' });
if (request?.request) {
const method = request.request.method?.toUpperCase();
const url = normalizeUrlPath(request.request.url);
if (!isPathInsideCollection(filePath, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked: ${filePath}`);
} else {
for (const removed of diff.removed) {
const removedPath = normalizeUrlPath(removed.path);
if (method === removed.method.toUpperCase() && url === removedPath) {
fs.unlinkSync(filePath);
break;
}
}
}
}
} catch (err) {
console.error(`Error parsing file ${filePath}:`, err);
}
}
}
};
findAndRemoveRequest(collectionPath);
}
// Remove local-only endpoints (endpoints in collection but not in spec)
// Verify file content before deleting — the file may have been modified by the user
// between the drift scan and sync execution, making the pre-computed filePath stale.
if (localOnlyToRemove?.length > 0) {
for (const endpoint of localOnlyToRemove) {
if (endpoint.filePath) {
const fullPath = path.resolve(collectionPath, endpoint.filePath);
if (!isPathInsideCollection(fullPath, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked in localOnlyToRemove: ${endpoint.filePath}`);
continue;
}
if (fs.existsSync(fullPath)) {
try {
const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
const content = fs.readFileSync(fullPath, 'utf8');
const parsed = parseRequest(content, { format: fileFormat });
if (parsed?.request) {
const fileMethod = parsed.request.method?.toUpperCase();
const fileUrlPath = normalizeUrlPath(parsed.request.url);
if (fileMethod === endpoint.method && fileUrlPath === endpoint.path) {
fs.unlinkSync(fullPath);
}
}
} catch (err) {
console.error(`[OpenAPI Sync] Error verifying file before removal ${endpoint.filePath}:`, err);
}
}
}
}
}
if (addNewRequests && diff.added?.length > 0 && newCollection) {
for (const endpoint of diff.added) {
const normalizedPath = normalizeUrlPath(endpoint.path);
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
const newItem = result?.item;
if (newItem) {
// Check if endpoint already exists in collection (prevents overwriting user customizations)
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
} else {
// Truly new — create file in the appropriate folder
let targetFolder = collectionPath;
if (result.folderName && groupBy === 'tags') {
targetFolder = await ensureTagFolder(collectionPath, result.folderName, format);
}
const requestContent = await stringifyRequestViaWorker(newItem, { format });
const sanitizedFilename = `${sanitizeName(newItem.name || path.basename(newItem.filename || '', `.${format}`))}.${format}`;
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
}
}
}
}
// Handle modified endpoints with conflict resolutions
// endpointDecisions: { endpointId: 'keep-mine' | 'accept-incoming' }
// Only apply changes for endpoints marked as 'accept-incoming' or not in decisions (default: apply)
if (diff.modified?.length > 0 && newCollection) {
for (const endpoint of diff.modified) {
// Check if user chose to keep their version
const endpointId = endpoint.id || `${endpoint.method.toUpperCase()}:${normalizeUrlPath(endpoint.path)}`;
const decision = endpointDecisions[endpointId];
if (decision === 'keep-mine') {
continue;
}
// Apply incoming changes for this endpoint
const normalizedPath = normalizeUrlPath(endpoint.path);
const result = findItemInCollection(newCollection.items, endpoint.method, endpoint.path);
const newItem = result?.item;
const existingFile = findRequestFileOnDisk(collectionPath, endpoint.method.toUpperCase(), normalizedPath);
if (newItem && existingFile) {
const mergedRequest = mergeSpecIntoRequest(existingFile.request, newItem);
const content = await stringifyRequestViaWorker(mergedRequest, { format: existingFile.fileFormat });
await writeFile(existingFile.filePath, content);
}
}
}
// Handle drifted endpoints to reset (collection differs from stored spec)
// These are endpoints where user chose 'accept-incoming' to reset to spec
if (driftedToReset?.length > 0) {
// Reuse newCollection if available, otherwise fall back to stored spec
let driftCollection = newCollection;
if (!driftCollection) {
const applySpecEntry = getSpecEntryForUrl(collectionPath, sourceUrl);
const storedSpecPath = applySpecEntry ? path.join(getSpecsDir(), applySpecEntry.filename) : null;
if (storedSpecPath && fs.existsSync(storedSpecPath)) {
try {
driftCollection = openApiToBruno(parseSpec(fs.readFileSync(storedSpecPath, 'utf8')), { groupBy });
} catch (err) {
console.error('[OpenAPI Sync] Error converting stored spec for drift reset:', err);
}
}
}
if (driftCollection) {
const specItemsMap = buildSpecItemsMap(driftCollection.items || []);
for (const endpoint of driftedToReset) {
const specItem = specItemsMap.get(endpoint.id);
if (!specItem) {
continue;
}
if (endpoint.filePath) {
const fullPath = path.resolve(collectionPath, endpoint.filePath);
if (!isPathInsideCollection(fullPath, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked in driftedToReset: ${endpoint.filePath}`);
continue;
}
if (fs.existsSync(fullPath)) {
try {
const fileFormat = fullPath.endsWith('.yml') || fullPath.endsWith('.yaml') ? 'yml' : 'bru';
const existingContent = fs.readFileSync(fullPath, 'utf8');
const existingRequest = parseRequest(existingContent, { format: fileFormat });
const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true });
const content = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat });
await writeFile(fullPath, content);
} catch (err) {
console.error(`[OpenAPI Sync] Error resetting drifted endpoint ${endpoint.id}:`, err);
}
}
}
}
}
}
// Save spec only if we have a valid spec
if (diff.newSpec && typeof diff.newSpec === 'object') {
const specContent = diff.newSpecContent || JSON.stringify(diff.newSpec, null, 2);
await saveOpenApiSpecFile({ collectionPath, content: specContent, sourceUrl });
}
const openapiSync = brunoConfig.openapi || [];
const syncIdx = openapiSync.findIndex((e) => e.sourceUrl === sourceUrl);
if (syncIdx !== -1) {
const updated = {
...openapiSync[syncIdx],
lastSyncDate: new Date().toISOString()
};
// Only update specHash when we have a valid newSpec, otherwise preserve existing hash
if (diff.newSpec) {
updated.specHash = generateSpecHash(diff.newSpec);
}
openapiSync[syncIdx] = updated;
}
brunoConfig.openapi = openapiSync;
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
return { success: true };
} catch (error) {
console.error('Error applying OpenAPI sync:', error);
throw error;
}
});
// Update OpenAPI sync configuration (e.g., source URL)
ipcMain.handle('renderer:update-openapi-sync-config', async (event, { collectionPath, oldSourceUrl, config }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
// Merge new config into existing entry (allowlist keys only)
const allowedKeys = ['sourceUrl', 'groupBy', 'lastSyncDate', 'specHash', 'autoCheck', 'autoCheckInterval'];
const sanitizedConfig = {};
for (const key of allowedKeys) {
if (key in config) {
sanitizedConfig[key] = config[key];
}
}
// sourceUrl is required — it identifies which entry to create/update
if (!sanitizedConfig.sourceUrl) {
throw new Error('sourceUrl is required to update openapi sync config');
}
// Validate sourceUrl — reject protocol-based non-http(s) URLs (e.g. ftp://, file://)
if (sanitizedConfig.sourceUrl.includes('://') && !isValidHttpUrl(sanitizedConfig.sourceUrl)) {
throw new Error('Invalid URL: only http and https URLs are allowed');
}
// Convert absolute local file paths to collection-relative (git-shareable)
if (path.isAbsolute(sanitizedConfig.sourceUrl)) {
sanitizedConfig.sourceUrl = path.relative(collectionPath, sanitizedConfig.sourceUrl);
}
// If sourceUrl is changing, remove the old entry and its metadata
const openapi = brunoConfig.openapi || [];
if (oldSourceUrl && oldSourceUrl !== sanitizedConfig.sourceUrl) {
const filteredOpenapi = openapi.filter((e) => e.sourceUrl !== oldSourceUrl);
brunoConfig.openapi = filteredOpenapi;
// Clean up metadata entry for old sourceUrl (keep spec file for potential re-use)
const meta = loadSpecMetadata();
if (meta[collectionPath]) {
meta[collectionPath] = meta[collectionPath].filter((e) => e.sourceUrl !== oldSourceUrl);
if (meta[collectionPath].length === 0) delete meta[collectionPath];
saveSpecMetadata(meta);
}
}
// Apply defaults for new entries
const updatedOpenapi = brunoConfig.openapi || [];
const idx = updatedOpenapi.findIndex((e) => e.sourceUrl === sanitizedConfig.sourceUrl);
const isNewEntry = idx === -1;
if (isNewEntry) {
if (!('autoCheck' in sanitizedConfig)) sanitizedConfig.autoCheck = true;
if (!('autoCheckInterval' in sanitizedConfig)) sanitizedConfig.autoCheckInterval = 5;
updatedOpenapi.push(sanitizedConfig);
} else {
updatedOpenapi[idx] = { ...updatedOpenapi[idx], ...sanitizedConfig };
}
brunoConfig.openapi = updatedOpenapi;
// Save updated config
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
return { success: true };
} catch (error) {
console.error('Error updating OpenAPI sync config:', error);
throw error;
}
});
// Save OpenAPI spec file and update sync metadata (used by both connect and import flows)
ipcMain.handle('renderer:save-openapi-spec', async (event, { collectionPath, specContent, sourceUrl }) => {
try {
await saveSpecAndUpdateMetadata({ collectionPath, specContent, sourceUrl });
return { success: true };
} catch (error) {
console.error('Error saving OpenAPI spec file:', error);
throw error;
}
});
// Fetch OpenAPI spec content from a remote URL or local file path
ipcMain.handle('renderer:fetch-openapi-spec', async (event, {
collectionUid, collectionPath, sourceUrl, environmentContext
}) => {
try {
const result = await fetchSpecFromSource({ collectionUid, collectionPath, sourceUrl, environmentContext });
if (result.error) return { error: result.error, errorCode: result.errorCode };
if (!isValidOpenApiSpec(result.spec)) {
const error = result.spec?.swagger
? 'Swagger 2.0 is not supported. Please convert your spec to OpenAPI 3.x.'
: 'The source does not contain a valid OpenAPI 3.x specification';
return { error };
}
return { content: result.content };
} catch (error) {
return { error: error.message || 'Failed to fetch spec' };
}
});
// Read stored OpenAPI spec file from AppData
ipcMain.handle('renderer:read-openapi-spec', async (event, { collectionPath, sourceUrl }) => {
try {
const entry = getSpecEntryForUrl(collectionPath, sourceUrl);
if (!entry) return { error: 'Spec file not found' };
const specPath = path.join(getSpecsDir(), entry.filename);
if (!fs.existsSync(specPath)) return { error: 'Spec file not found' };
return { content: fs.readFileSync(specPath, 'utf8') };
} catch (error) {
return { error: error.message || 'Failed to read spec file' };
}
});
// Remove OpenAPI sync configuration (disconnect sync)
ipcMain.handle('renderer:remove-openapi-sync-config', async (event, { collectionPath, sourceUrl, deleteSpecFile = false }) => {
try {
const { format, brunoConfig, collectionRoot } = loadBrunoConfig(collectionPath);
// Remove matching openapi entry from config array
if (brunoConfig.openapi?.length) {
brunoConfig.openapi = brunoConfig.openapi.filter((e) => e.sourceUrl !== sourceUrl);
if (brunoConfig.openapi.length === 0) {
delete brunoConfig.openapi;
}
}
// Save updated config
await saveBrunoConfig(collectionPath, format, brunoConfig, collectionRoot);
// Remove spec file from AppData if user opted in
const meta = loadSpecMetadata();
const entries = meta[collectionPath] || [];
const entry = entries.find((e) => e.sourceUrl === sourceUrl);
if (entry && deleteSpecFile) {
const specPath = path.join(getSpecsDir(), entry.filename);
if (fs.existsSync(specPath)) fs.unlinkSync(specPath);
}
meta[collectionPath] = entries.filter((e) => e.sourceUrl !== sourceUrl);
if (meta[collectionPath].length === 0) delete meta[collectionPath];
saveSpecMetadata(meta);
return { success: true };
} catch (error) {
console.error('Error removing OpenAPI sync config:', error);
throw error;
}
});
// Add missing endpoints to collection (from stored spec)
ipcMain.handle('renderer:add-missing-endpoints', async (event, { collectionPath, endpoints }) => {
try {
const { format, brunoConfig } = loadBrunoConfig(collectionPath);
const groupBy = brunoConfig?.openapi?.[0]?.groupBy || 'tags';
const specCollection = loadStoredSpecCollection(collectionPath, brunoConfig);
let addedCount = 0;
for (const endpoint of endpoints) {
const result = findItemInCollection(specCollection.items, endpoint.method, endpoint.path);
if (result) {
const { item: specItem, folderName } = result;
let targetFolder = collectionPath;
// Use folder name from spec collection structure
if (folderName && groupBy === 'tags') {
targetFolder = await ensureTagFolder(collectionPath, folderName, format);
}
const requestContent = await stringifyRequestViaWorker(specItem, { format });
const sanitizedFilename = `${sanitizeName(specItem.name || path.basename(specItem.filename || '', `.${format}`))}.${format}`;
await writeFile(path.join(targetFolder, sanitizedFilename), requestContent);
addedCount++;
}
}
return { success: true, addedCount };
} catch (error) {
console.error('Error adding missing endpoints:', error);
throw error;
}
});
// Reset modified endpoints to match the spec
ipcMain.handle('renderer:reset-endpoints-to-spec', async (event, { collectionPath, endpoints }) => {
try {
const { brunoConfig } = loadBrunoConfig(collectionPath);
const specCollection = loadStoredSpecCollection(collectionPath, brunoConfig);
let resetCount = 0;
for (const endpoint of endpoints) {
// Find the spec version of this endpoint
const specItem = findItemInCollection(specCollection.items, endpoint.method, endpoint.path)?.item;
if (specItem && endpoint.pathname) {
if (!isPathInsideCollection(endpoint.pathname, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked in reset-endpoints: ${endpoint.pathname}`);
continue;
}
try {
const fileFormat = endpoint.pathname.endsWith('.yml') || endpoint.pathname.endsWith('.yaml') ? 'yml' : 'bru';
const existingContent = fs.readFileSync(endpoint.pathname, 'utf8');
const existingRequest = parseRequest(existingContent, { format: fileFormat });
const mergedRequest = mergeSpecIntoRequest(existingRequest, specItem, { fullReset: true });
const requestContent = await stringifyRequestViaWorker(mergedRequest, { format: fileFormat });
await writeFile(endpoint.pathname, requestContent);
resetCount++;
} catch (err) {
console.error(`[OpenAPI Sync] Error resetting endpoint ${endpoint.pathname}:`, err);
}
}
}
return { success: true, resetCount };
} catch (error) {
console.error('Error resetting endpoints to spec:', error);
throw error;
}
});
// Delete endpoints from collection
ipcMain.handle('renderer:delete-endpoints', async (event, { collectionPath, endpoints }) => {
try {
let deletedCount = 0;
for (const endpoint of endpoints) {
if (endpoint.pathname && fs.existsSync(endpoint.pathname)) {
if (!isPathInsideCollection(endpoint.pathname, collectionPath)) {
console.error(`[OpenAPI Sync] Path traversal blocked in delete-endpoints: ${endpoint.pathname}`);
continue;
}
fs.unlinkSync(endpoint.pathname);
deletedCount++;
}
}
return { success: true, deletedCount };
} catch (error) {
console.error('Error deleting endpoints:', error);
throw error;
}
});
};
module.exports = registerOpenAPISyncIpc;
module.exports.saveSpecAndUpdateMetadata = saveSpecAndUpdateMetadata;
module.exports.cleanupSpecFilesForCollection = cleanupSpecFilesForCollection;