fix(openapi):validate API spec imports (#8322)

* fix(openapi):validate API spec imports and resolve preview to error state instead of hanging

* fix(openapi):allows opening of malformed files with correct extension

* fix(openapi):reject non-OpenAPI content in spec preview before timeout

* test(openapi): add unit + e2e coverage for spec import validation

* refactor(openapi):centralise spec error messages and fix dialog restore

* fix:removed nested try catch

* test:added common locators

* refactor:removed repeated lines

* refactor:removed redundant file reads

---------

Co-authored-by: Adwait Aayush <adwaitaayush@Adwaits-MacBook-Air.local>
This commit is contained in:
adwait-bruno
2026-07-07 22:05:23 +05:30
committed by GitHub
parent 0bba66a590
commit ad14fdfba5
13 changed files with 375 additions and 48 deletions

View File

@@ -1,11 +1,34 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import get from 'lodash/get';
import jsyaml from 'js-yaml';
import { useTheme } from 'providers/Theme';
import { useSelector } from 'react-redux';
import { IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import { IconAlertCircle, IconDeviceFloppy, IconLoader2 } from '@tabler/icons';
import { isOpenApiSpec } from 'utils/importers/openapi-collection';
import CodeEditor from './FileEditor/CodeEditor/index';
import Swagger from './Renderers/Swagger';
import { useDragResize } from 'hooks/useDragResize';
import { SPEC_PREVIEW_ERRORS } from './constants';
const PREVIEW_TIMEOUT_MS = 15000;
const getPreviewParseError = (content) => {
if (!content || typeof content !== 'string') return null;
let parsed;
try {
parsed = JSON.parse(content);
} catch {
try {
parsed = jsyaml.load(content);
} catch {
return SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON;
}
}
if (!isOpenApiSpec(parsed)) {
return SPEC_PREVIEW_ERRORS.INVALID_OPENAPI;
}
return null;
};
const MIN_LEFT_PANE_WIDTH = 300;
const MIN_RIGHT_PANE_WIDTH = 450;
@@ -51,16 +74,40 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
: { flex: '1 1 50%', minWidth: 0 };
const [swaggerReady, setSwaggerReady] = useState(false);
const [previewError, setPreviewError] = useState(null);
const previewTimeoutRef = useRef(null);
useEffect(() => {
setSwaggerReady(false);
clearTimeout(previewTimeoutRef.current);
if (!content || !content.trim()) {
setPreviewError(SPEC_PREVIEW_ERRORS.EMPTY);
return;
}
const parseErr = getPreviewParseError(content);
if (parseErr) {
setPreviewError(parseErr);
return;
}
setPreviewError(null);
previewTimeoutRef.current = setTimeout(() => {
setPreviewError(SPEC_PREVIEW_ERRORS.TIMEOUT);
}, PREVIEW_TIMEOUT_MS);
return () => clearTimeout(previewTimeoutRef.current);
}, [content]);
const handleSwaggerComplete = useCallback(() => {
// Double rAF: wait for one full paint cycle so Swagger is actually on screen
// before hiding the loader — avoids a flash of unrendered content.
requestAnimationFrame(() => {
requestAnimationFrame(() => setSwaggerReady(true));
requestAnimationFrame(() => {
clearTimeout(previewTimeoutRef.current);
setSwaggerReady(true);
});
});
}, []);
@@ -101,19 +148,33 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
className="api-spec-right-pane relative"
style={{ flex: '1 1 50%', minWidth: 0 }}
>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
{previewError ? (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
className="absolute inset-0 flex items-center justify-center p-8"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
<div className="flex flex-col items-center gap-3 text-center opacity-70">
<IconAlertCircle size={28} strokeWidth={1.5} />
<span className="text-sm">{previewError}</span>
</div>
</div>
) : (
<>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
className="absolute inset-0 flex items-center justify-center gap-2"
style={{ background: theme.bg }}
>
<div className="flex items-center justify-center gap-2 opacity-70">
<IconLoader2 size={20} className="animate-spin" />
<span>Generating preview</span>
</div>
</div>
)}
</>
)}
</div>
</section>

View File

@@ -0,0 +1,6 @@
export const SPEC_PREVIEW_ERRORS = {
EMPTY: 'Unable to render preview: No API definition provided.',
INVALID_YAML_JSON: 'Unable to render preview: content is not a valid YAML or JSON.',
INVALID_OPENAPI: 'Unable to render preview: content is not a valid OpenAPI specification.',
TIMEOUT: 'Preview timed out. The spec may be too large or contain unsupported content.'
} as const;

View File

@@ -11,6 +11,10 @@ export const convertOpenapiToBruno = (data, options = {}) => {
};
export const isOpenApiSpec = (data) => {
if (!data || typeof data !== 'object') {
return false;
}
if (typeof data.info !== 'object' || data.info === null) {
return false;
}

View File

@@ -1,8 +1,9 @@
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const { dialog, ipcMain } = require('electron');
const { normalizeAndResolvePath } = require('../utils/filesystem');
const { generateUidBasedOnHash } = require('../utils/common');
const { parseApiSpecContent } = require('../utils/apiSpecs');
const {
addApiSpecToWorkspace,
readWorkspaceConfig,
@@ -11,6 +12,18 @@ const {
const DEFAULT_WORKSPACE_NAME = 'My Workspace';
const INVALID_EXTENSION_MESSAGE
= 'Invalid file format. Please select a valid OpenAPI spec in YAML or JSON format.';
const VALID_API_SPEC_EXTENSIONS = ['.yaml', '.yml', '.json'];
const validateApiSpec = (filePath) => {
const ext = path.extname(filePath).toLowerCase();
if (!VALID_API_SPEC_EXTENSIONS.includes(ext)) {
throw new Error(INVALID_EXTENSION_MESSAGE);
}
};
const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => {
if (isDefault) {
return {
@@ -24,7 +37,8 @@ const prepareWorkspaceConfigForClient = (workspaceConfig, isDefault) => {
const openApiSpecDialog = async (win, watcher, options = {}) => {
const { filePaths } = await dialog.showOpenDialog(win, {
properties: ['openFile', 'createFile']
properties: ['openFile', 'createFile'],
filters: [{ name: 'OpenAPI Spec', extensions: ['yaml', 'yml', 'json'] }]
});
if (filePaths && filePaths[0]) {
@@ -39,6 +53,8 @@ const openApiSpecDialog = async (win, watcher, options = {}) => {
const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
try {
validateApiSpec(apiSpecPath);
const uid = generateUidBasedOnHash(apiSpecPath);
if (options.workspacePath) {
@@ -76,28 +92,22 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
if (!watcher.hasWatcher(apiSpecPath)) {
ipcMain.emit('main:apispec-opened', win, apiSpecPath, uid, options.workspacePath);
} else {
const rawContent = fs.readFileSync(apiSpecPath, 'utf8');
const extension = path.extname(apiSpecPath);
win.webContents.send('main:apispec-tree-updated', 'addFile', {
pathname: apiSpecPath,
uid: uid,
raw: require('fs').readFileSync(apiSpecPath, 'utf8'),
name: require('path').basename(apiSpecPath, require('path').extname(apiSpecPath)),
filename: require('path').basename(apiSpecPath),
json: (() => {
const ext = require('path').extname(apiSpecPath).toLowerCase();
const content = require('fs').readFileSync(apiSpecPath, 'utf8');
if (ext === '.yaml' || ext === '.yml') {
return require('js-yaml').load(content);
} else if (ext === '.json') {
return JSON.parse(content);
}
return null;
})()
raw: rawContent,
name: path.basename(apiSpecPath, path.extname(apiSpecPath)),
filename: path.basename(apiSpecPath),
json: parseApiSpecContent(rawContent, extension)
});
}
} catch (err) {
if (!options.dontSendDisplayErrors) {
win.webContents.send('main:display-error', {
error: err.message || 'An error occurred while opening the apiSpec'
message: err.message || 'An error occurred while opening the apiSpec'
});
}
}
@@ -105,5 +115,6 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => {
module.exports = {
openApiSpec,
openApiSpecDialog
openApiSpecDialog,
INVALID_EXTENSION_MESSAGE
};

View File

@@ -1,29 +1,16 @@
const _ = require('lodash');
const fs = require('fs');
const path = require('path');
const fs = require('node:fs');
const path = require('node:path');
const chokidar = require('chokidar');
const { getApiSpecUid } = require('../cache/apiSpecUids');
const yaml = require('js-yaml');
const { isDirectory } = require('../utils/filesystem');
const { safeParseJSON } = require('../utils/common');
const { parseApiSpecContent } = require('../utils/apiSpecs');
const hasApiSpecExtension = (filename) => {
if (!filename || typeof filename !== 'string') return false;
return ['yaml', 'yml', 'json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`));
};
const parseApiSpecContent = (pathname) => {
const extension = path.extname(pathname).toLowerCase();
let content = fs.readFileSync(pathname, 'utf8');
if (extension === '.yaml' || extension === '.yml') {
return yaml.load(content);
} else if (extension === '.json') {
return safeParseJSON(content);
}
return null;
};
const hydrateApiSpecWithUuid = (apiSpec, pathname) => {
apiSpec.uid = getApiSpecUid(pathname);
return apiSpec;
@@ -34,9 +21,11 @@ const add = async (win, pathname) => {
try {
const basename = path.basename(pathname);
const file = {};
const apiSpecContent = parseApiSpecContent(pathname);
const raw = fs.readFileSync(pathname, 'utf8');
const extension = path.extname(pathname);
const apiSpecContent = parseApiSpecContent(raw, extension);
file.raw = fs.readFileSync(pathname, 'utf8');
file.raw = raw;
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
file.filename = basename;
file.pathname = pathname;
@@ -53,9 +42,11 @@ const change = async (win, pathname) => {
try {
const basename = path.basename(pathname);
const file = {};
const apiSpecContent = parseApiSpecContent(pathname);
const raw = fs.readFileSync(pathname, 'utf8');
const extension = path.extname(pathname);
const apiSpecContent = parseApiSpecContent(raw, extension);
file.raw = fs.readFileSync(pathname, 'utf8');
file.raw = raw;
file.name = apiSpecContent?.info?.title || basename.split('.')[0];
file.filename = basename;
file.pathname = pathname;

View File

@@ -0,0 +1,19 @@
const yaml = require('js-yaml');
const parseApiSpecContent = (content, extension) => {
const ext = (extension || '').toLowerCase();
try {
if (ext === '.yaml' || ext === '.yml') {
return yaml.load(content);
} else if (ext === '.json') {
return JSON.parse(content);
}
} catch {
return null;
}
return null;
};
module.exports = { parseApiSpecContent };

View File

@@ -0,0 +1,107 @@
const path = require('path');
const fs = require('fs');
const os = require('os');
const { INVALID_EXTENSION_MESSAGE } = require('../../src/app/apiSpecs');
jest.mock('electron', () => ({
dialog: { showOpenDialog: jest.fn() },
ipcMain: { emit: jest.fn() }
}));
const { ipcMain } = require('electron');
const { openApiSpec } = require('../../src/app/apiSpecs');
describe('openApiSpec', () => {
let tmpDir;
let win;
let watcher;
const writeSpecFile = (filename, content) => {
const filePath = path.join(tmpDir, filename);
fs.writeFileSync(filePath, content);
return filePath;
};
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-apispec-'));
win = { webContents: { send: jest.fn() } };
watcher = { hasWatcher: jest.fn() };
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test.each(['spec.txt', 'spec.xml', 'spec', 'spec.yamlx'])(
'rejects a file with an unsupported extension (%s)',
async (filename) => {
const specPath = writeSpecFile(filename, 'This is a plain text file, not an OpenAPI spec.');
await openApiSpec(win, watcher, specPath);
expect(win.webContents.send).toHaveBeenCalledWith('main:display-error', {
message: INVALID_EXTENSION_MESSAGE
});
expect(ipcMain.emit).not.toHaveBeenCalled();
}
);
test.each(['openapi.yaml', 'openapi.yml', 'openapi.json', 'openapi.YAML', 'openapi.Json'])(
'accepts a file with a supported extension (%s)',
async (filename) => {
const specPath = writeSpecFile(filename, '{}');
watcher.hasWatcher.mockReturnValue(false);
await openApiSpec(win, watcher, specPath);
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
expect(ipcMain.emit).toHaveBeenCalledWith('main:apispec-opened', win, specPath, expect.any(String), undefined);
}
);
test('does not send a display error when dontSendDisplayErrors is set', async () => {
const specPath = writeSpecFile('spec.txt', 'not a spec');
await openApiSpec(win, watcher, specPath, { dontSendDisplayErrors: true });
expect(win.webContents.send).not.toHaveBeenCalled();
});
test('opens a valid spec by emitting main:apispec-opened', async () => {
const specPath = writeSpecFile('openapi.yaml', 'openapi: 3.0.0\ninfo:\n title: Test\n version: 1.0.0\npaths: {}\n');
watcher.hasWatcher.mockReturnValue(false);
await openApiSpec(win, watcher, specPath);
expect(ipcMain.emit).toHaveBeenCalledWith('main:apispec-opened', win, specPath, expect.any(String), undefined);
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
});
test('opens a malformed file with a valid extension without throwing, resolving json to null', async () => {
const specPath = writeSpecFile('malformed.yaml', 'openapi: 3.0.0\ninfo:\n title: Test\n version: : :\npaths: [');
watcher.hasWatcher.mockReturnValue(true);
await openApiSpec(win, watcher, specPath);
expect(win.webContents.send).toHaveBeenCalledWith(
'main:apispec-tree-updated',
'addFile',
expect.objectContaining({ pathname: specPath, json: null })
);
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
});
test('opens a broken JSON file with a valid extension without throwing, resolving json to null', async () => {
const specPath = writeSpecFile('broken.json', '{\n "openapi": "3.0.0",\n "info": {\n "title": "Test"\n "version": "1.0.0"\n },\n "paths": {\n');
watcher.hasWatcher.mockReturnValue(true);
await openApiSpec(win, watcher, specPath);
expect(win.webContents.send).toHaveBeenCalledWith(
'main:apispec-tree-updated',
'addFile',
expect.objectContaining({ pathname: specPath, json: null })
);
expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything());
});
});