mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-24 13:15:40 +00:00
- 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.
1715 lines
67 KiB
JavaScript
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;
|