Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -161,7 +164,7 @@ const SpecViewer = ({ content, readOnly, onSave, leftPaneWidth, onLeftPaneWidthC
) : (
<>
<div style={{ visibility: swaggerReady ? 'visible' : 'hidden', height: '100%' }}>
<Swagger spec={content} onComplete={handleSwaggerComplete} />
<Swagger spec={resolvedSpec || content} onComplete={handleSwaggerComplete} />
</div>
{!swaggerReady && (
<div
Expand Down
3 changes: 2 additions & 1 deletion packages/bruno-app/src/components/ApiSpecPanel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const ApiSpecPanel = () => {
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) => {
Expand Down Expand Up @@ -87,6 +87,7 @@ const ApiSpecPanel = () => {
</div>
<SpecViewer
content={raw}
resolvedSpec={resolvedJson}
onSave={(content) => dispatch(saveApiSpecToFile({ uid, content }))}
leftPaneWidth={leftPaneWidth ?? null}
onLeftPaneWidthChange={handleLeftPaneWidthChange}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -23,21 +23,23 @@ export const apiSpecSlice = createSlice({
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
apiSpec.resolvedJson = resolvedJson;
} else {
const newApiSpec = {
name,
raw,
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);
Expand All @@ -47,6 +49,7 @@ export const apiSpecSlice = createSlice({
apiSpec.filename = filename;
apiSpec.pathname = pathname;
apiSpec.json = json;
apiSpec.resolvedJson = resolvedJson;
}
},
saveApiSpec: (state, action) => {
Expand Down
1 change: 1 addition & 0 deletions packages/bruno-electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions packages/bruno-electron/src/app/apiSpecs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -94,14 +94,17 @@ 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,
uid: uid,
raw: rawContent,
name: path.basename(apiSpecPath, path.extname(apiSpecPath)),
filename: path.basename(apiSpecPath),
json: parseApiSpecContent(rawContent, extension)
json: apiSpecContent,
resolvedJson: resolvedJson
});
}
} catch (err) {
Expand Down
38 changes: 33 additions & 5 deletions packages/bruno-electron/src/app/apiSpecsWatcher.js
Comment thread
adwait-bruno marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,71 @@ 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);
const file = {};
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);
const file = {};
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);
}
Expand Down Expand Up @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion packages/bruno-electron/src/utils/apiSpecs.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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;
Comment thread
adwait-bruno marked this conversation as resolved.

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 };
Loading
Loading