diff --git a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js index ae1a9cdeb..cf4e1dc33 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js @@ -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 }} > -
- -
- {!swaggerReady && ( + {previewError ? (
-
- - Generating preview… +
+ + {previewError}
+ ) : ( + <> +
+ +
+ {!swaggerReady && ( +
+
+ + Generating preview… +
+
+ )} + )}
diff --git a/packages/bruno-app/src/components/ApiSpecPanel/constants.ts b/packages/bruno-app/src/components/ApiSpecPanel/constants.ts new file mode 100644 index 000000000..a48288643 --- /dev/null +++ b/packages/bruno-app/src/components/ApiSpecPanel/constants.ts @@ -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; diff --git a/packages/bruno-app/src/utils/importers/openapi-collection.js b/packages/bruno-app/src/utils/importers/openapi-collection.js index c28b6f926..afbbd7efd 100644 --- a/packages/bruno-app/src/utils/importers/openapi-collection.js +++ b/packages/bruno-app/src/utils/importers/openapi-collection.js @@ -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; } diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js index cb4350dc3..6b0cb1827 100644 --- a/packages/bruno-electron/src/app/apiSpecs.js +++ b/packages/bruno-electron/src/app/apiSpecs.js @@ -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 }; diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index 1aee5c35b..dc07d3f0f 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -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; diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js new file mode 100644 index 000000000..a57b2f110 --- /dev/null +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -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 }; diff --git a/packages/bruno-electron/tests/app/apiSpecs.spec.js b/packages/bruno-electron/tests/app/apiSpecs.spec.js new file mode 100644 index 000000000..63a38b03f --- /dev/null +++ b/packages/bruno-electron/tests/app/apiSpecs.spec.js @@ -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()); + }); +}); diff --git a/tests/import/openapi/api-spec-panel-validation.spec.ts b/tests/import/openapi/api-spec-panel-validation.spec.ts new file mode 100644 index 000000000..b956ae21a --- /dev/null +++ b/tests/import/openapi/api-spec-panel-validation.spec.ts @@ -0,0 +1,74 @@ +import { test, expect } from '../../../playwright'; +import * as path from 'path'; +import { openApiSpecFromDialog, openApiSpecSidebarItem } from '../../utils/page/openapi/render-spec'; +import { SPEC_PREVIEW_ERRORS } from '../../../packages/bruno-app/src/components/ApiSpecPanel/constants'; +import { INVALID_EXTENSION_MESSAGE } from '../../../packages/bruno-electron/src/app/apiSpecs'; + +test.describe('API Spec Panel - open & preview validation', () => { + test.beforeAll(async ({ electronApp }) => { + await electronApp.evaluate(({ dialog }) => { + (dialog as any).__savedShowOpenDialog = dialog.showOpenDialog; + }); + }); + + test.afterAll(async ({ electronApp }) => { + await electronApp.evaluate(({ dialog }) => { + dialog.showOpenDialog = (dialog as any).__savedShowOpenDialog; + delete (dialog as any).__savedShowOpenDialog; + }); + }); + + test('Reject a file with an unsupported extension', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-invalid-extension.txt'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await expect( + page.getByText(INVALID_EXTENSION_MESSAGE) + ).toBeVisible(); + }); + + test('Show the empty-spec message when opening an empty file', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-empty.yaml'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'openapi-empty'); + await expect(page.getByText(SPEC_PREVIEW_ERRORS.EMPTY)).toBeVisible(); + }); + + test('Show a parse error when opening malformed YAML', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-malformed.yaml'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'openapi-malformed'); + await expect( + page.getByText(SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON) + ).toBeVisible(); + }); + + test('Show a parse error when opening malformed JSON', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-broken.json'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'openapi-broken'); + await expect( + page.getByText(SPEC_PREVIEW_ERRORS.INVALID_YAML_JSON) + ).toBeVisible(); + }); + + test('Show a spec error when the file parses but is not a valid OpenAPI document', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-missing-info.yaml'); + + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'openapi-missing-info'); + // Parses as an object but lacks the openapi/swagger + info contract, so it must fail + // fast with a precise message rather than falling through to the preview timeout. + await expect( + page.getByText(SPEC_PREVIEW_ERRORS.INVALID_OPENAPI) + ).toBeVisible(); + }); + + test('Render a valid spec without any preview error', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-simple.json'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'Simple Test API'); + // Panel opens for the valid spec (filename shown in the header) and no preview error appears + await expect(page.getByText('openapi-simple.json')).toBeVisible(); + await expect(page.getByText(/Unable to render preview/i)).toHaveCount(0); + }); +}); diff --git a/tests/import/openapi/fixtures/openapi-broken.json b/tests/import/openapi/fixtures/openapi-broken.json new file mode 100644 index 000000000..9e75d94cf --- /dev/null +++ b/tests/import/openapi/fixtures/openapi-broken.json @@ -0,0 +1,19 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Malformed OpenAPI" + "version": "1.0.0" + }, + "paths": { + "/test": { + "get": { + "summary": "Test endpoint", + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/tests/import/openapi/fixtures/openapi-empty.yaml b/tests/import/openapi/fixtures/openapi-empty.yaml new file mode 100644 index 000000000..e69de29bb diff --git a/tests/import/openapi/fixtures/openapi-invalid-extension.txt b/tests/import/openapi/fixtures/openapi-invalid-extension.txt new file mode 100644 index 000000000..a1681035a --- /dev/null +++ b/tests/import/openapi/fixtures/openapi-invalid-extension.txt @@ -0,0 +1 @@ +This is a plain text file, not an OpenAPI spec. \ No newline at end of file diff --git a/tests/utils/page/locators.ts b/tests/utils/page/locators.ts index 25f106c36..2655e0b2d 100644 --- a/tests/utils/page/locators.ts +++ b/tests/utils/page/locators.ts @@ -1,7 +1,11 @@ import { Page, Locator } from '../../../playwright'; +import { buildApiSpecPanelLocators } from './openapi/render-spec'; export const buildCommonLocators = (page: Page) => ({ runner: () => page.getByTestId('run-button'), + openApi: { + render: buildApiSpecPanelLocators(page) + }, saveButton: () => page .locator('.infotip') .filter({ hasText: /^Save/ }), diff --git a/tests/utils/page/openapi/render-spec.ts b/tests/utils/page/openapi/render-spec.ts new file mode 100644 index 000000000..dae854b76 --- /dev/null +++ b/tests/utils/page/openapi/render-spec.ts @@ -0,0 +1,30 @@ +import { test, Page, ElectronApplication } from '../../../../playwright'; + +export const buildApiSpecPanelLocators = (page: Page) => ({ + addMenuButton: () => page.getByTestId('api-specs-header-add-menu'), + openApiSpecMenuItem: () => page.getByTestId('api-specs-header-add-menu-open-api-spec'), + sidebarItem: (name: string) => page.locator('.api-spec-item').filter({ hasText: name }) +}); + +export const openApiSpecFromDialog = async ( + page: Page, + electronApp: ElectronApplication, + filePath: string +): Promise => { + await test.step(`Open API spec from path: ${filePath}`, async () => { + await electronApp.evaluate(({ dialog }, filePath) => { + dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [filePath] }); + }, filePath); + + const { addMenuButton, openApiSpecMenuItem } = buildApiSpecPanelLocators(page); + await addMenuButton().click(); + await openApiSpecMenuItem().click(); + }); +}; + +export const openApiSpecSidebarItem = async (page: Page, name: string): Promise => { + await test.step(`Open API spec sidebar item "${name}"`, async () => { + const { sidebarItem } = buildApiSpecPanelLocators(page); + await sidebarItem(name).click(); + }); +};