diff --git a/package-lock.json b/package-lock.json index 07b2c4f3c3c..ded4c6c9e6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,24 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.2.1.tgz", + "integrity": "sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + }, + "peerDependencies": { + "@types/json-schema": "^7.0.15" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "license": "Apache-2.0", @@ -28817,6 +28835,7 @@ "dependencies": { "@ai-sdk/anthropic": "3.0.15", "@ai-sdk/openai": "3.0.12", + "@apidevtools/json-schema-ref-parser": "14.2.1", "@aws-sdk/credential-providers": "3.1019.0", "@grpc/grpc-js": "^1.14.4", "@grpc/proto-loader": "^0.7.13", diff --git a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js index cf4e1dc3330..4df3230e7e3 100644 --- a/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js +++ b/packages/bruno-app/src/components/ApiSpecPanel/SpecViewer.js @@ -38,12 +38,15 @@ const MIN_RIGHT_PANE_WIDTH = 450; * * Props: * - content (string) The spec content (YAML/JSON string) + * - resolvedSpec (object|null) The same spec with the files it references inlined, for + * multi-file specs. The preview renders this when present, since it cannot + * resolve `./sibling.yaml` itself; the editor always shows `content`. * - readOnly (boolean) If true, editor is not editable and save icon is hidden * - onSave (fn) Called with current editor content on save (editable mode only) * - leftPaneWidth (number|null) Persisted left pane width in px; null = use 50/50 default * - onLeftPaneWidthChange (fn) Persist the new width (called on mouseup / double-click / resize-clamp) */ -const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => { +const SpecViewer = ({ content, resolvedSpec, readOnly, onSave, leftPaneWidth, onLeftPaneWidthChange }) => { const { displayedTheme, theme } = useTheme(); const preferences = useSelector((state) => state.app.preferences); @@ -161,7 +164,7 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC ) : ( <>
- +
{!swaggerReady && (
{ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref); let apiSpec = find(apiSpecs, (c) => c.uid === activeApiSpecUid); - const { filename, pathname, raw, uid, leftPaneWidth } = apiSpec || {}; + const { filename, pathname, raw, uid, leftPaneWidth, resolvedJson } = apiSpec || {}; const handleLeftPaneWidthChange = useCallback( (w) => { @@ -87,6 +87,7 @@ const ApiSpecPanel = () => {
dispatch(saveApiSpecToFile({ uid, content }))} leftPaneWidth={leftPaneWidth ?? null} onLeftPaneWidthChange={handleLeftPaneWidthChange} diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js index 5a8dcea39ca..773fce58a91 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/apiSpec.js @@ -12,7 +12,7 @@ export const apiSpecSlice = createSlice({ initialState, reducers: { apiSpecAddFileEvent: (state, action) => { - const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {}; + const { name, raw, uid, filename, pathname, json, resolvedJson } = action?.payload?.data || {}; if (!uid) { toast.error('Error adding API spec'); } @@ -23,6 +23,7 @@ export const apiSpecSlice = createSlice({ apiSpec.filename = filename; apiSpec.pathname = pathname; apiSpec.json = json; + apiSpec.resolvedJson = resolvedJson; } else { const newApiSpec = { name, @@ -30,14 +31,15 @@ export const apiSpecSlice = createSlice({ uid, filename, pathname, - json + json, + resolvedJson }; state.apiSpecs.push(newApiSpec); } state.activeApiSpecUid = uid; }, apiSpecChangeFileEvent: (state, action) => { - const { name, raw, uid, filename, pathname, json } = action?.payload?.data || {}; + const { name, raw, uid, filename, pathname, json, resolvedJson } = action?.payload?.data || {}; if (!uid) return; const apiSpec = findApiSpecByUid(state.apiSpecs, uid); @@ -47,6 +49,7 @@ export const apiSpecSlice = createSlice({ apiSpec.filename = filename; apiSpec.pathname = pathname; apiSpec.json = json; + apiSpec.resolvedJson = resolvedJson; } }, saveApiSpec: (state, action) => { diff --git a/packages/bruno-electron/package.json b/packages/bruno-electron/package.json index 5b6c4de156f..e5d73ae81b2 100644 --- a/packages/bruno-electron/package.json +++ b/packages/bruno-electron/package.json @@ -32,6 +32,7 @@ "dependencies": { "@ai-sdk/anthropic": "3.0.15", "@ai-sdk/openai": "3.0.12", + "@apidevtools/json-schema-ref-parser": "14.2.1", "@aws-sdk/credential-providers": "3.1019.0", "@grpc/grpc-js": "^1.14.4", "@grpc/proto-loader": "^0.7.13", diff --git a/packages/bruno-electron/src/app/apiSpecs.js b/packages/bruno-electron/src/app/apiSpecs.js index 683ede06678..350376ed60d 100644 --- a/packages/bruno-electron/src/app/apiSpecs.js +++ b/packages/bruno-electron/src/app/apiSpecs.js @@ -3,7 +3,7 @@ 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 { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../utils/apiSpecs'); const { addApiSpecToWorkspace, readWorkspaceConfig, @@ -94,6 +94,8 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { } else { const rawContent = fs.readFileSync(apiSpecPath, 'utf8'); const extension = path.extname(apiSpecPath); + const apiSpecContent = parseApiSpecContent(rawContent, extension); + const { resolvedJson } = await resolveExternalApiSpecRefs(apiSpecContent, apiSpecPath); win.webContents.send('main:apispec-tree-updated', 'addFile', { pathname: apiSpecPath, @@ -101,7 +103,8 @@ const openApiSpec = async (win, watcher, apiSpecPath, options = {}) => { raw: rawContent, name: path.basename(apiSpecPath, path.extname(apiSpecPath)), filename: path.basename(apiSpecPath), - json: parseApiSpecContent(rawContent, extension) + json: apiSpecContent, + resolvedJson: resolvedJson }); } } catch (err) { diff --git a/packages/bruno-electron/src/app/apiSpecsWatcher.js b/packages/bruno-electron/src/app/apiSpecsWatcher.js index dc07d3f0f48..47c9db0734d 100644 --- a/packages/bruno-electron/src/app/apiSpecsWatcher.js +++ b/packages/bruno-electron/src/app/apiSpecsWatcher.js @@ -4,19 +4,29 @@ const path = require('node:path'); const chokidar = require('chokidar'); const { getApiSpecUid } = require('../cache/apiSpecUids'); const { isDirectory } = require('../utils/filesystem'); -const { parseApiSpecContent } = require('../utils/apiSpecs'); +const { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../utils/apiSpecs'); const hasApiSpecExtension = (filename) => { if (!filename || typeof filename !== 'string') return false; return ['yaml', 'yml', 'json'].some((ext) => filename.toLowerCase().endsWith(`.${ext}`)); }; +const isSpecItself = (pathname, watchPath) => path.normalize(pathname) === path.normalize(watchPath); + const hydrateApiSpecWithUuid = (apiSpec, pathname) => { apiSpec.uid = getApiSpecUid(pathname); return apiSpec; }; -const add = async (win, pathname) => { +const syncRefFileWatchers = ({ watcher, watchedRefFilePaths }, refFilePaths) => { + const unwatched = refFilePaths.filter((filePath) => !watchedRefFilePaths.has(filePath)); + if (!unwatched.length) return; + + unwatched.forEach((filePath) => watchedRefFilePaths.add(filePath)); + watcher.add(unwatched); +}; + +const add = async (win, pathname, refWatchState) => { if (!hasApiSpecExtension(pathname)) return; try { const basename = path.basename(pathname); @@ -24,20 +34,23 @@ const add = async (win, pathname) => { const raw = fs.readFileSync(pathname, 'utf8'); const extension = path.extname(pathname); const apiSpecContent = parseApiSpecContent(raw, extension); + const { resolvedJson, refFilePaths } = await resolveExternalApiSpecRefs(apiSpecContent, pathname); file.raw = raw; file.name = apiSpecContent?.info?.title || basename.split('.')[0]; file.filename = basename; file.pathname = pathname; file.json = apiSpecContent; + file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'addFile', file); + syncRefFileWatchers(refWatchState, refFilePaths); } catch (err) { console.error(err); } }; -const change = async (win, pathname) => { +const change = async (win, pathname, refWatchState) => { if (!hasApiSpecExtension(pathname)) return; try { const basename = path.basename(pathname); @@ -45,14 +58,17 @@ const change = async (win, pathname) => { const raw = fs.readFileSync(pathname, 'utf8'); const extension = path.extname(pathname); const apiSpecContent = parseApiSpecContent(raw, extension); + const { resolvedJson, refFilePaths } = await resolveExternalApiSpecRefs(apiSpecContent, pathname); file.raw = raw; file.name = apiSpecContent?.info?.title || basename.split('.')[0]; file.filename = basename; file.pathname = pathname; file.json = apiSpecContent; + file.resolvedJson = resolvedJson; hydrateApiSpecWithUuid(file, pathname); win.webContents.send('main:apispec-tree-updated', 'changeFile', file); + syncRefFileWatchers(refWatchState, refFilePaths); } catch (err) { console.error(err); } @@ -111,9 +127,21 @@ class ApiSpecWatcher { depth: 20 }); + const refWatchState = { watcher, watchedRefFilePaths: new Set() }; + watcher - .on('add', (pathname) => add(win, pathname, apiSpecUid, watchPath, workspacePath)) - .on('change', (pathname) => change(win, pathname, apiSpecUid, watchPath, workspacePath)); + .on('add', (pathname) => { + if (isSpecItself(pathname, watchPath)) return add(win, watchPath, refWatchState); + if (refWatchState.watchedRefFilePaths.has(path.resolve(pathname))) return; + change(win, watchPath, refWatchState); + }) + .on('change', () => change(win, watchPath, refWatchState)) + .on('unlink', (pathname) => { + if (isSpecItself(pathname, watchPath)) return; + refWatchState.watchedRefFilePaths.delete(path.resolve(pathname)); + change(win, watchPath, refWatchState); + }) + .on('error', (err) => console.error(`API spec watcher error for ${watchPath}:`, err)); self.watchers[watchPath] = watcher; }, 100); diff --git a/packages/bruno-electron/src/utils/apiSpecs.js b/packages/bruno-electron/src/utils/apiSpecs.js index a57b2f1105b..5d2738ed9ac 100644 --- a/packages/bruno-electron/src/utils/apiSpecs.js +++ b/packages/bruno-electron/src/utils/apiSpecs.js @@ -1,4 +1,14 @@ +const fs = require('node:fs'); +const path = require('node:path'); const yaml = require('js-yaml'); +const { $RefParser } = require('@apidevtools/json-schema-ref-parser'); + +const REF_PARSER_OPTIONS = { + resolve: { external: true, http: false }, + continueOnError: true +}; + +const URI_SCHEME_REGEX = /^[a-z][a-z\d+\-.]+:/i; const parseApiSpecContent = (content, extension) => { const ext = (extension || '').toLowerCase(); @@ -16,4 +26,46 @@ const parseApiSpecContent = (content, extension) => { return null; }; -module.exports = { parseApiSpecContent }; +const externalRefTarget = (ref, specDir) => { + if (typeof ref !== 'string') return null; + + const [filePath] = ref.split('#'); + if (!filePath || URI_SCHEME_REGEX.test(filePath)) return null; + + return path.resolve(specDir, filePath); +}; + +const containsExternalFileRef = (node, specDir, visited = new Set()) => { + if (!node || typeof node !== 'object' || visited.has(node)) return false; + visited.add(node); + + const refTarget = externalRefTarget(node.$ref, specDir); + if (refTarget && fs.existsSync(refTarget)) return true; + + return Object.values(node).some((value) => containsExternalFileRef(value, specDir, visited)); +}; + +const refFilePathsOf = (parser, apiSpecPath) => { + const root = path.resolve(apiSpecPath); + + return (parser?.$refs.paths('file') ?? []) + .map((refFilePath) => path.resolve(refFilePath)) + .filter((refFilePath) => refFilePath !== root); +}; + +const resolveExternalApiSpecRefs = async (json, apiSpecPath) => { + let parser; + try { + if (!containsExternalFileRef(json, path.dirname(apiSpecPath))) { + return { resolvedJson: null, refFilePaths: [] }; + } + parser = new $RefParser(); + const resolvedJson = await parser.bundle(apiSpecPath, structuredClone(json), REF_PARSER_OPTIONS); + + return { resolvedJson, refFilePaths: refFilePathsOf(parser, apiSpecPath) }; + } catch { + return { resolvedJson: parser?.schema ?? null, refFilePaths: refFilePathsOf(parser, apiSpecPath) }; + } +}; + +module.exports = { parseApiSpecContent, resolveExternalApiSpecRefs }; diff --git a/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js new file mode 100644 index 00000000000..415210ebe2d --- /dev/null +++ b/packages/bruno-electron/src/utils/tests/apiSpecs.spec.js @@ -0,0 +1,156 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { parseApiSpecContent, resolveExternalApiSpecRefs } = require('../apiSpecs'); + +describe('parseApiSpecContent', () => { + it('parses yaml and json by extension', () => { + expect(parseApiSpecContent('openapi: 3.1.0', '.yaml')).toEqual({ openapi: '3.1.0' }); + expect(parseApiSpecContent('openapi: 3.1.0', '.YML')).toEqual({ openapi: '3.1.0' }); + expect(parseApiSpecContent('{"openapi":"3.1.0"}', '.json')).toEqual({ openapi: '3.1.0' }); + }); + + it('returns null for unknown extensions and unparseable content', () => { + expect(parseApiSpecContent('openapi: 3.1.0', '.txt')).toBeNull(); + expect(parseApiSpecContent('{oops', '.json')).toBeNull(); + }); +}); + +describe('resolveExternalApiSpecRefs', () => { + let specDir; + + const writeSpecFile = (filename, content) => { + const filePath = path.join(specDir, filename); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content); + return filePath; + }; + + beforeEach(() => { + specDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-api-spec-')); + }); + + afterEach(() => { + fs.rmSync(specDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + }); + + it('inlines a file referenced relative to the spec, not the process cwd', async () => { + writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n'); + const specPath = writeSpecFile('openapi.yaml', ''); + + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( + { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, + specPath + ); + + expect(resolved.paths['/hello']).toEqual({ get: { operationId: 'hello' } }); + }); + + it('inlines refs from a nested directory and rebases their internal pointers', async () => { + writeSpecFile('schemas/hello.yaml', 'Hello:\n type: object\nGreeting:\n $ref: "#/Hello"\n'); + const specPath = writeSpecFile('openapi.yaml', ''); + + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( + { + openapi: '3.1.0', + components: { + schemas: { + Hello: { $ref: './schemas/hello.yaml#/Hello' }, + Greeting: { $ref: './schemas/hello.yaml#/Greeting' } + } + } + }, + specPath + ); + + expect(resolved.components.schemas.Hello).toEqual({ type: 'object' }); + expect(resolved.components.schemas.Greeting).toEqual({ $ref: '#/components/schemas/Hello' }); + }); + + it('leaves the document it is given untouched', async () => { + writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n'); + const specPath = writeSpecFile('openapi.yaml', ''); + const json = { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }; + + await resolveExternalApiSpecRefs(json, specPath); + + expect(json.paths['/hello']).toEqual({ $ref: './endpoint.yaml' }); + }); + + it('follows a ref chain through a third file', async () => { + writeSpecFile('shared.yaml', 'Ok:\n description: ok\n'); + writeSpecFile('endpoint.yaml', 'get:\n responses:\n "200":\n $ref: "./shared.yaml#/Ok"\n'); + const specPath = writeSpecFile('openapi.yaml', ''); + + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( + { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, + specPath + ); + + expect(resolved.paths['/hello'].get.responses['200']).toEqual({ description: 'ok' }); + }); + + it('turns a ref cycle across files into an internal ref the renderer can receive', async () => { + writeSpecFile('endpoint.yaml', 'get:\n responses:\n "200":\n $ref: "./openapi.yaml#/paths/~1hello"\n'); + const specPath = writeSpecFile('openapi.yaml', 'paths:\n /hello:\n $ref: "./endpoint.yaml"\n'); + + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( + { openapi: '3.1.0', paths: { '/hello': { $ref: './endpoint.yaml' } } }, + specPath + ); + + expect(resolved.paths['/hello'].get.responses['200']).toEqual({ $ref: '#/paths/~1hello' }); + expect(() => JSON.stringify(resolved)).not.toThrow(); + }); + + it('returns null when there is nothing external to inline', async () => { + const specPath = writeSpecFile('openapi.yaml', ''); + const internalOnly = { + openapi: '3.1.0', + paths: { '/hello': { get: { responses: { 200: { $ref: '#/components/responses/Ok' } } } } } + }; + + expect((await resolveExternalApiSpecRefs(internalOnly, specPath)).resolvedJson).toBeNull(); + expect((await resolveExternalApiSpecRefs(null, specPath)).resolvedJson).toBeNull(); + }); + + it('leaves refs the parser cannot follow alone', async () => { + const specPath = writeSpecFile('openapi.yaml', ''); + const remoteRef = { openapi: '3.1.0', paths: { '/hello': { $ref: 'https://example.com/endpoint.yaml' } } }; + // An OpenAPI 3.1 `$ref` to an in-document `$id` is indistinguishable from a relative file path. + const idRef = { + openapi: '3.1.0', + components: { schemas: { Hello: { $id: 'hello.json', type: 'object' }, Greeting: { $ref: 'hello.json' } } } + }; + + expect((await resolveExternalApiSpecRefs(remoteRef, specPath)).resolvedJson).toBeNull(); + expect((await resolveExternalApiSpecRefs(idRef, specPath)).resolvedJson).toBeNull(); + }); + + it('inlines what it can when one ref among several is unresolvable', async () => { + writeSpecFile('endpoint.yaml', 'get:\n operationId: hello\n'); + const specPath = writeSpecFile('openapi.yaml', ''); + + const { resolvedJson: resolved } = await resolveExternalApiSpecRefs( + { + openapi: '3.1.0', + paths: { + '/hello': { $ref: './endpoint.yaml' }, + '/missing': { $ref: './deleted.yaml' } + } + }, + specPath + ); + + expect(resolved.paths['/hello']).toEqual({ get: { operationId: 'hello' } }); + expect(resolved.paths['/missing']).toEqual({ $ref: './deleted.yaml' }); + }); + + it('does not hang on a self-referencing document', async () => { + const specPath = writeSpecFile('openapi.yaml', ''); + const json = { openapi: '3.1.0', components: {} }; + json.components.self = json; + + expect((await resolveExternalApiSpecRefs(json, specPath)).resolvedJson).toBeNull(); + }); +}); diff --git a/packages/bruno-electron/tests/app/apiSpecs.spec.js b/packages/bruno-electron/tests/app/apiSpecs.spec.js index 63a38b03f48..8aafe721826 100644 --- a/packages/bruno-electron/tests/app/apiSpecs.spec.js +++ b/packages/bruno-electron/tests/app/apiSpecs.spec.js @@ -91,6 +91,44 @@ describe('openApiSpec', () => { expect(win.webContents.send).not.toHaveBeenCalledWith('main:display-error', expect.anything()); }); + test('sends the referenced files inlined as resolvedJson for a multi-file spec', async () => { + writeSpecFile('endpoint.yaml', 'get:\n summary: Hello endpoint\n operationId: hello\n'); + const specPath = writeSpecFile( + 'openapi.yaml', + 'openapi: 3.1.0\ninfo:\n title: Test API\n version: 1.0.0\npaths:\n /hello:\n $ref: "./endpoint.yaml"\n' + ); + watcher.hasWatcher.mockReturnValue(true); + + await openApiSpec(win, watcher, specPath); + + expect(win.webContents.send).toHaveBeenCalledWith( + 'main:apispec-tree-updated', + 'addFile', + expect.objectContaining({ + json: expect.objectContaining({ paths: { '/hello': { $ref: './endpoint.yaml' } } }), + resolvedJson: expect.objectContaining({ + paths: { '/hello': { get: { summary: 'Hello endpoint', operationId: 'hello' } } } + }) + }) + ); + }); + + test('sends resolvedJson as null for a single-file spec', async () => { + const specPath = writeSpecFile( + 'openapi.yaml', + 'openapi: 3.1.0\ninfo:\n title: Test API\n version: 1.0.0\npaths:\n /hello:\n get:\n responses:\n "200":\n description: ok\n' + ); + watcher.hasWatcher.mockReturnValue(true); + + await openApiSpec(win, watcher, specPath); + + expect(win.webContents.send).toHaveBeenCalledWith( + 'main:apispec-tree-updated', + 'addFile', + expect.objectContaining({ resolvedJson: null }) + ); + }); + 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); diff --git a/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js b/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js new file mode 100644 index 00000000000..7ec0989b05d --- /dev/null +++ b/packages/bruno-electron/tests/app/apiSpecsWatcher.spec.js @@ -0,0 +1,53 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const ApiSpecWatcher = require('../../src/app/apiSpecsWatcher'); + +describe('ApiSpecWatcher ref file watching', () => { + let specDir; + let win; + let apiSpecWatcher; + + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const specUpdates = () => win.webContents.send.mock.calls.filter(([channel]) => channel === 'main:apispec-tree-updated'); + + const waitForUpdates = async (predicate, description) => { + const deadline = Date.now() + 10000; + while (Date.now() < deadline) { + if (predicate()) return; + await sleep(25); + } + throw new Error(`Timed out waiting for ${description}. Updates so far: ${specUpdates().length}`); + }; + + beforeEach(() => { + specDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'bruno-apispec-watcher-'))); + win = { webContents: { send: jest.fn() } }; + apiSpecWatcher = new ApiSpecWatcher(); + }); + + afterEach(async () => { + await apiSpecWatcher.closeAllWatchers(); + fs.rmSync(specDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + }); + + it('resolves the spec once on open and again when a referenced file changes', async () => { + const refPath = path.join(specDir, 'endpoint.yaml'); + const specPath = path.join(specDir, 'openapi.yaml'); + fs.writeFileSync(refPath, 'get:\n operationId: hello\n'); + fs.writeFileSync(specPath, 'openapi: 3.1.0\npaths:\n /hello:\n $ref: \'./endpoint.yaml\'\n'); + + apiSpecWatcher.addWatcher(win, specPath, 'api-spec-uid', {}); + await waitForUpdates(() => specUpdates().length >= 1, 'the spec to be picked up'); + await sleep(600); + expect(specUpdates()).toHaveLength(1); + expect(specUpdates()[0][2].resolvedJson.paths['/hello'].get.operationId).toBe('hello'); + + fs.writeFileSync(refPath, 'get:\n operationId: helloEdited\n'); + + await waitForUpdates(() => specUpdates().length > 1, 'the ref file edit to refresh the spec'); + const [, type, file] = specUpdates()[specUpdates().length - 1]; + expect(type).toBe('changeFile'); + expect(file.resolvedJson.paths['/hello'].get.operationId).toBe('helloEdited'); + }, 20000); +}); diff --git a/tests/import/openapi/api-spec-panel-validation.spec.ts b/tests/import/openapi/api-spec-panel-validation.spec.ts index b956ae21a07..ade16805834 100644 --- a/tests/import/openapi/api-spec-panel-validation.spec.ts +++ b/tests/import/openapi/api-spec-panel-validation.spec.ts @@ -63,6 +63,16 @@ test.describe('API Spec Panel - open & preview validation', () => { ).toBeVisible(); }); + test('Render a spec whose paths live in a referenced file', async ({ page, electronApp }) => { + const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-multifile.yaml'); + await openApiSpecFromDialog(page, electronApp, openApiFile); + await openApiSpecSidebarItem(page, 'Multi File API'); + // "Hello endpoint" lives only in openapi-multifile-endpoint.yaml, so a preview showing it + // proves the referenced file was resolved relative to the spec and not to the app's resources. + await expect(page.getByText('Hello endpoint').first()).toBeVisible(); + await expect(page.getByText(/Could not resolve reference/i)).toHaveCount(0); + }); + 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); diff --git a/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml b/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml new file mode 100644 index 00000000000..11e0672b4dc --- /dev/null +++ b/tests/import/openapi/fixtures/openapi-multifile-endpoint.yaml @@ -0,0 +1,6 @@ +get: + summary: Hello endpoint + operationId: hello + responses: + '200': + description: Successful response diff --git a/tests/import/openapi/fixtures/openapi-multifile.yaml b/tests/import/openapi/fixtures/openapi-multifile.yaml new file mode 100644 index 00000000000..82362114046 --- /dev/null +++ b/tests/import/openapi/fixtures/openapi-multifile.yaml @@ -0,0 +1,7 @@ +openapi: 3.1.0 +info: + title: Multi File API + version: 1.0.0 +paths: + /hello: + $ref: './openapi-multifile-endpoint.yaml'