Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import Modal from 'components/Modal';
import StyledWrapper from './StyledWrapper';
import demoImage from './demo.png';
import { useApp } from 'providers/App';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading } from 'utils/collections/index';
import { transformCollectionToSaveToExportAsFile, findCollectionByUid, areItemsLoading, getGlobalEnvironmentVariables } from 'utils/collections/index';
import { brunoToOpenCollection } from '@usebruno/converters';
import { sanitizeName } from 'utils/common/regex';
import { escapeHtml } from 'utils/response';
import { resolveCollectionForHtmlDocumentation } from 'utils/exporters/html-documentation';

const CDN_BASE_URL = 'https://cdn.opencollection.com';

Expand Down Expand Up @@ -66,6 +67,15 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
const collection = useSelector((state) =>
findCollectionByUid(state.collections.collections, collectionUid)
);
const { globalEnvironments, activeGlobalEnvironmentUid } = useSelector((state) => state.globalEnvironments);
const globalEnvironmentVariables = useMemo(
() =>
getGlobalEnvironmentVariables({
globalEnvironments,
activeGlobalEnvironmentUid
}),
[globalEnvironments, activeGlobalEnvironmentUid]
);

const isLoading = useMemo(
() => (collection ? areItemsLoading(collection) : false),
Expand All @@ -75,6 +85,9 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
const handleGenerate = useCallback(() => {
try {
const collectionCopy = cloneDeep(collection);
collectionCopy.globalEnvironmentVariables = globalEnvironmentVariables;
resolveCollectionForHtmlDocumentation(collectionCopy);

Comment on lines 101 to +105
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved by sanitizing variables during documentation export: process.env is excluded and all variables marked as secret are replaced with REDACTED to prevent leakage of sensitive data.

const transformedCollection = transformCollectionToSaveToExportAsFile(collectionCopy);
const openCollection = brunoToOpenCollection(transformedCollection);

Expand Down Expand Up @@ -114,7 +127,7 @@ const GenerateDocumentation = ({ onClose, collectionUid }) => {
console.error('Error generating documentation:', error);
toast.error('Failed to generate documentation');
}
}, [collection, version, onClose]);
}, [collection, globalEnvironmentVariables, version, onClose]);

if (!collection) {
return <CollectionNotFound onClose={onClose} />;
Expand Down
178 changes: 178 additions & 0 deletions packages/bruno-app/src/utils/exporters/html-documentation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { interpolate, interpolateObject } from '@usebruno/common';
import { each } from 'lodash';
import { flattenItems, getAllVariables, isItemARequest } from 'utils/collections';
import { interpolateUrl, interpolateUrlPathParams } from 'utils/url';

const interpolateString = (value, variables, options = {}) => {
if (typeof value !== 'string') {
return value;
}

return interpolate(value, variables, options);
};

const interpolateEntries = (entries = [], variables = {}) => {
if (!Array.isArray(entries)) {
return entries;
}

return entries.map((entry) => {
if (!entry || typeof entry !== 'object') {
return entry;
}

return {
...entry,
name: interpolateString(entry.name, variables),
value: Array.isArray(entry.value)
? entry.value.map((value) => interpolateString(value, variables))
: interpolateString(entry.value, variables)
};
});
};

const interpolateWsMessages = (messages = [], variables = {}) => {
if (!Array.isArray(messages)) {
return messages;
}

return messages.map((message) => {
if (!message || typeof message !== 'object') {
return message;
}

let escapeJSONStrings = message?.type === 'json';

if (!escapeJSONStrings && typeof message?.content === 'string') {
try {
JSON.parse(message.content);
escapeJSONStrings = true;
} catch (error) {
// no-op, plain text payload
}
}

return {
...message,
name: interpolateString(message.name, variables),
content: interpolateString(message.content, variables, { escapeJSONStrings })
};
});
};

const interpolateRequestBody = (body, variables = {}) => {
if (!body || typeof body !== 'object') {
return;
}

switch (body.mode) {
case 'json':
body.json = interpolateString(body.json, variables, { escapeJSONStrings: true });
break;
case 'text':
body.text = interpolateString(body.text, variables);
break;
case 'xml':
body.xml = interpolateString(body.xml, variables);
break;
case 'graphql':
body.graphql = interpolateString(body.graphql, variables, { escapeJSONStrings: true });
break;
case 'sparql':
body.sparql = interpolateString(body.sparql, variables);
break;
case 'formUrlEncoded':
body.formUrlEncoded = interpolateEntries(body.formUrlEncoded, variables);
break;
case 'multipartForm':
body.multipartForm = Array.isArray(body.multipartForm)
? body.multipartForm.map((entry) => {
if (!entry || typeof entry !== 'object') {
return entry;
}

if (entry.type && entry.type !== 'text') {
return entry;
}

return {
...entry,
name: interpolateString(entry.name, variables),
value: interpolateString(entry.value, variables)
};
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Interpolate multipart field names for non-text entries too.

Line 94 currently returns early for non-text multipart entries, so name placeholders can remain unresolved in exported docs. Keep skipping value interpolation for file/binary parts, but still resolve name.

💡 Proposed fix
-            if (entry.type && entry.type !== 'text') {
-              return entry;
-            }
-
-            return {
-              ...entry,
-              name: interpolateString(entry.name, variables),
-              value: interpolateString(entry.value, variables)
-            };
+            const interpolatedEntry = {
+              ...entry,
+              name: interpolateString(entry.name, variables)
+            };
+
+            if (entry.type && entry.type !== 'text') {
+              return interpolatedEntry;
+            }
+
+            return {
+              ...interpolatedEntry,
+              value: interpolateString(entry.value, variables)
+            };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (entry.type && entry.type !== 'text') {
return entry;
}
return {
...entry,
name: interpolateString(entry.name, variables),
value: interpolateString(entry.value, variables)
};
const interpolatedEntry = {
...entry,
name: interpolateString(entry.name, variables)
};
if (entry.type && entry.type !== 'text') {
return interpolatedEntry;
}
return {
...interpolatedEntry,
value: interpolateString(entry.value, variables)
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-app/src/utils/exporters/html-documentation.js` around lines 94
- 102, The current early return for non-text multipart entries prevents name
interpolation; instead, always set name: interpolateString(entry.name,
variables) for every entry and only skip value interpolation when entry.type !==
'text' (i.e., preserve the existing behavior of not interpolating file/binary
values). Update the block that references entry and interpolateString so it
returns { ...entry, name: interpolateString(entry.name, variables), value:
(entry.type === 'text' ? interpolateString(entry.value, variables) :
entry.value) } rather than returning early for non-text entries.

✅ Addressed in commits f09ef8d to 25e0914

})
: body.multipartForm;
break;
case 'grpc':
body.grpc = Array.isArray(body.grpc)
? body.grpc.map((message) => ({
...message,
name: interpolateString(message?.name, variables),
content: interpolateString(message?.content, variables, { escapeJSONStrings: true })
}))
: body.grpc;
Comment on lines +152 to +159
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard malformed gRPC messages before spreading.

This branch assumes every body.grpc entry is an object. A null/undefined entry will throw and abort docs generation, while the WS branch already tolerates malformed items.

💡 Proposed fix
     case 'grpc':
       body.grpc = Array.isArray(body.grpc)
-        ? body.grpc.map((message) => ({
-            ...message,
-            name: interpolateString(message?.name, variables),
-            content: interpolateString(message?.content, variables, { escapeJSONStrings: true })
-          }))
+        ? body.grpc.map((message) => {
+            if (!message || typeof message !== 'object') {
+              return message;
+            }
+
+            return {
+              ...message,
+              name: interpolateString(message.name, variables),
+              content: interpolateString(message.content, variables, { escapeJSONStrings: true })
+            };
+          })
         : body.grpc;
       break;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-app/src/utils/exporters/html-documentation.js` around lines
152 - 159, The grpc branch assumes each body.grpc item is an object and uses the
spread operator on message, which will throw for null/undefined; update the case
'grpc' mapping to first guard each item (e.g., skip or normalize non-objects)
before spreading and calling interpolateString so malformed entries are
tolerated like the WS branch—specifically, in the case 'grpc' handler around
body.grpc.map((message) => ({ ... })) add a check that message is a non-null
object (or replace it with an empty object) before using ...message and calling
interpolateString on message.name/message.content.

break;
case 'ws':
body.ws = interpolateWsMessages(body.ws, variables);
break;
default:
break;
}
};

const resolveRequestForHtmlDocumentation = (request, variables = {}) => {
if (!request || typeof request !== 'object') {
return;
}

const interpolatedUrl = interpolateUrl({
url: request.url,
variables
}) || request.url;
const hasPathParams = Array.isArray(request.params)
? request.params.some((param) => param?.type === 'path' && param?.enabled !== false)
: false;
const hasNonHttpProtocol = /^(ws|wss|grpc|grpcs):\/\//i.test(interpolatedUrl || '');

request.url = hasPathParams && !hasNonHttpProtocol
? interpolateUrlPathParams(interpolatedUrl, request.params || [], variables, { raw: true })
: interpolatedUrl;
request.headers = interpolateEntries(request.headers, variables);
request.params = interpolateEntries(request.params, variables);

if (request.auth && typeof request.auth === 'object') {
request.auth = interpolateObject(request.auth, variables);
}

if (request.body) {
interpolateRequestBody(request.body, variables);
}

request.docs = interpolateString(request.docs, variables);
};

export const resolveCollectionForHtmlDocumentation = (collection) => {
if (!collection?.items?.length) {
return collection;
}

const items = flattenItems(collection.items);

each(items, (item) => {
if (!isItemARequest(item) || item.isTransient) {
return;
}

const variables = getAllVariables(collection, item);

resolveRequestForHtmlDocumentation(item.request, variables);

if (Array.isArray(item.examples)) {
each(item.examples, (example) => {
resolveRequestForHtmlDocumentation(example?.request, variables);
});
}
});

return collection;
};
135 changes: 135 additions & 0 deletions packages/bruno-app/src/utils/exporters/html-documentation.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { resolveCollectionForHtmlDocumentation } from './html-documentation';

describe('resolveCollectionForHtmlDocumentation', () => {
it('resolves global variables in request URLs for HTML docs export', () => {
const collection = {
uid: 'collection-1',
activeEnvironmentUid: 'env-1',
globalEnvironmentVariables: {
url: 'https://postman-echo.com'
},
environments: [
{
uid: 'env-1',
variables: []
}
],
root: {
request: {
vars: {
req: []
}
}
},
items: [
{
uid: 'request-1',
type: 'http-request',
request: {
url: '{{url}}/get',
method: 'get',
headers: [],
params: [],
body: {
mode: 'none'
},
auth: {
mode: 'none'
},
vars: {
req: []
}
},
examples: []
}
]
};

resolveCollectionForHtmlDocumentation(collection);

expect(collection.items[0].request.url).toBe('https://postman-echo.com/get');
expect(collection.items[0].request.url).not.toContain('{{url}}');
});

it('resolves global, collection, and environment variables in request payloads', () => {
const collection = {
uid: 'collection-2',
activeEnvironmentUid: 'env-1',
globalEnvironmentVariables: {
url: 'https://postman-echo.com'
},
environments: [
{
uid: 'env-1',
variables: [
{
name: 'token',
value: 'env-token',
enabled: true
}
]
}
],
root: {
request: {
vars: {
req: [
{
name: 'endpoint',
value: 'get',
enabled: true
}
]
}
}
},
items: [
{
uid: 'request-2',
type: 'http-request',
request: {
url: '{{url}}/:resource',
method: 'get',
headers: [
{
uid: 'header-1',
name: 'Authorization',
value: 'Bearer {{token}}',
enabled: true
}
],
params: [
{
uid: 'param-1',
type: 'path',
name: 'resource',
value: '{{endpoint}}',
enabled: true
}
],
body: {
mode: 'text',
text: '{{url}}/{{endpoint}}?token={{token}}'
},
auth: {
mode: 'none'
},
vars: {
req: []
}
},
examples: []
}
]
};

resolveCollectionForHtmlDocumentation(collection);

const request = collection.items[0].request;

expect(request.url).toBe('https://postman-echo.com/get');
expect(request.headers[0].value).toBe('Bearer env-token');
expect(request.body.text).toBe('https://postman-echo.com/get?token=env-token');
expect(JSON.stringify(request)).not.toContain('{{');
});
});
Loading