feat: add path based grouping for openapi (#5638)

* feat: add path based grouping for openapi
This commit is contained in:
Pooja
2025-10-07 13:32:11 +05:30
committed by GitHub
parent 85319769a5
commit db6a639c15
17 changed files with 967 additions and 103 deletions

View File

@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { IconLoader2 } from '@tabler/icons';
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">{loadingMessage}</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
export default FullscreenLoader;

View File

@@ -1,12 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import { IconLoader2, IconFileImport } from '@tabler/icons';
import { IconFileImport } from '@tabler/icons';
import { toastError } from 'utils/common/error';
import Modal from 'components/Modal';
import jsyaml from 'js-yaml';
import { postmanToBruno, isPostmanCollection } from 'utils/importers/postman-collection';
import { convertInsomniaToBruno, isInsomniaCollection } from 'utils/importers/insomnia-collection';
import { convertOpenapiToBruno, isOpenApiSpec } from 'utils/importers/openapi-collection';
import { isOpenApiSpec, convertOpenapiToBruno } from 'utils/importers/openapi-collection';
import { processBrunoCollection } from 'utils/importers/bruno-collection';
import ImportSettings from 'components/Sidebar/ImportSettings';
import FullscreenLoader from './FullscreenLoader/index';
const convertFileToObject = async (file) => {
const text = await file.text();
@@ -26,60 +28,22 @@ const convertFileToObject = async (file) => {
}
};
const FullscreenLoader = ({ isLoading }) => {
const [loadingMessage, setLoadingMessage] = useState('');
// Messages to cycle through while loading
const loadingMessages = [
'Processing collection...',
'Analyzing requests...',
'Translating scripts...',
'Preparing collection...',
'Almost done...'
];
useEffect(() => {
if (!isLoading) return;
let messageIndex = 0;
const interval = setInterval(() => {
messageIndex = (messageIndex + 1) % loadingMessages.length;
setLoadingMessage(loadingMessages[messageIndex]);
}, 2000);
setLoadingMessage(loadingMessages[0]);
return () => clearInterval(interval);
}, [isLoading]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm transition-all duration-300">
<div className="flex flex-col items-center p-8 rounded-lg bg-white dark:bg-zinc-800 shadow-lg max-w-md text-center">
<IconLoader2 className="animate-spin h-12 w-12 mb-4" strokeWidth={1.5} />
<h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-50 mb-2">
{loadingMessage}
</h3>
<p className="text-sm text-zinc-500 dark:text-zinc-400">
This may take a moment depending on the collection size
</p>
</div>
</div>
);
};
const ImportCollection = ({ onClose, handleSubmit }) => {
const [isLoading, setIsLoading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [showImportSettings, setShowImportSettings] = useState(false);
const [openApiData, setOpenApiData] = useState(null);
const [groupingType, setGroupingType] = useState('tags');
const fileInputRef = useRef(null);
const handleDrag = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy';
}
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
@@ -87,30 +51,43 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
}
};
const handleImportSettings = () => {
try {
const collection = convertOpenapiToBruno(openApiData, { groupBy: groupingType });
handleSubmit({ collection });
} catch (err) {
console.error(err);
toastError(err, 'Failed to process OpenAPI specification');
}
};
const processFile = async (file) => {
setIsLoading(true);
try {
const data = await convertFileToObject(file);
if (!data) {
throw new Error('Failed to parse file content');
}
// Check if it's an OpenAPI spec and show settings
if (isOpenApiSpec(data)) {
setOpenApiData(data);
setIsLoading(false);
setShowImportSettings(true);
return;
}
let collection;
if (isPostmanCollection(data)) {
collection = await postmanToBruno(data);
}
else if (isInsomniaCollection(data)) {
} else if (isInsomniaCollection(data)) {
collection = convertInsomniaToBruno(data);
}
else if (isOpenApiSpec(data)) {
collection = convertOpenapiToBruno(data);
}
else {
} else {
collection = await processBrunoCollection(data);
}
handleSubmit({ collection });
} catch (err) {
toastError(err, 'Import collection failed');
@@ -123,7 +100,7 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
await processFile(e.dataTransfer.files[0]);
}
@@ -143,19 +120,23 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
return <FullscreenLoader isLoading={isLoading} />;
}
const acceptedFileTypes = [
'.json',
'.yaml',
'.yml',
'application/json',
'application/yaml',
'application/x-yaml'
]
const acceptedFileTypes = ['.json', '.yaml', '.yml', 'application/json', 'application/yaml', 'application/x-yaml'];
if (showImportSettings) {
return (
<ImportSettings
groupingType={groupingType}
setGroupingType={setGroupingType}
onClose={onClose}
onConfirm={handleImportSettings}
/>
);
}
return (
<Modal size="sm" title="Import Collection" hideFooter={true} handleCancel={onClose} dataTestId="import-collection-modal">
<div className="flex flex-col">
<div className="mb-4">
<div className="mb-4">
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Import from file</h3>
<div
onDragEnter={handleDrag}
@@ -164,16 +145,13 @@ const ImportCollection = ({ onClose, handleSubmit }) => {
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-6 transition-colors duration-200
${dragActive
? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-700'
}
${dragActive ? 'border-blue-500 bg-blue-50 dark:border-blue-400 dark:bg-blue-900/20' : 'border-gray-200 dark:border-gray-700'}
`}
>
<div className="flex flex-col items-center justify-center">
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
<IconFileImport
size={28}
className="text-gray-400 dark:text-gray-500 mb-3"
/>
<input
ref={fileInputRef}

View File

@@ -0,0 +1,22 @@
import styled from 'styled-components';
const Wrapper = styled.div`
.current-group {
background-color: ${(props) => props.theme.sidebar.badge.bg};
border-radius: 4px;
padding: 0.4rem;
cursor: pointer;
border: 1px solid ${(props) => props.theme.sidebar.badge.border || 'transparent'};
}
.current-group:hover {
background-color: ${(props) => props.theme.sidebar.badge.hoverBg || props.theme.sidebar.badge.bg};
}
/* Fix dropdown positioning */
[data-tippy-root] {
left: 0 !important;
}
`;
export default Wrapper;

View File

@@ -0,0 +1,84 @@
import React, { useRef, forwardRef } from 'react';
import { IconCaretDown } from '@tabler/icons';
import Dropdown from 'components/Dropdown';
import Modal from 'components/Modal';
import Portal from 'components/Portal';
import StyledWrapper from './StyledWrapper';
const groupingOptions = [
{ value: 'tags', label: 'Tags', description: 'Group requests by OpenAPI tags', testId: 'grouping-option-tags' },
{ value: 'path', label: 'Paths', description: 'Group requests by URL path structure', testId: 'grouping-option-path' }
];
const ImportSettings = ({
groupingType,
setGroupingType,
onClose,
onConfirm
}) => {
const dropdownTippyRef = useRef();
const onDropdownCreate = (ref) => {
dropdownTippyRef.current = ref;
};
const GroupingDropdownIcon = forwardRef((props, ref) => {
const selectedOption = groupingOptions.find((option) => option.value === groupingType);
return (
<div
ref={ref}
className="flex items-center justify-between w-full current-group"
data-testid="grouping-dropdown"
>
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{selectedOption.label}</div>
</div>
<IconCaretDown size={16} className="text-gray-400 ml-[0.25rem]" fill="currentColor" />
</div>
);
});
return (
<Portal>
<Modal
size="sm"
title="OpenAPI Import Settings"
handleCancel={onClose}
handleConfirm={onConfirm}
confirmText="Import"
dataTestId="import-settings-modal"
>
<StyledWrapper>
<div className="flex items-center">
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-2">Folder arrangement</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Select whether to create folders according to the spec's paths or tags.
</p>
</div>
<div className="relative">
<Dropdown onCreate={onDropdownCreate} icon={<GroupingDropdownIcon />} placement="bottom-start">
{groupingOptions.map((option) => (
<div
key={option.value}
className="dropdown-item"
data-testid={option.testId}
onClick={() => {
dropdownTippyRef?.current?.hide();
setGroupingType(option.value);
}}
>
{option.label}
</div>
))}
</Dropdown>
</div>
</div>
</StyledWrapper>
</Modal>
</Portal>
);
};
export default ImportSettings;

View File

@@ -1,9 +1,9 @@
import { BrunoError } from 'utils/common/error';
import { openApiToBruno } from '@usebruno/converters';
export const convertOpenapiToBruno = (data) => {
export const convertOpenapiToBruno = (data, options = {}) => {
try {
return openApiToBruno(data);
return openApiToBruno(data, options);
} catch (err) {
console.error('Error converting OpenAPI to Bruno:', err);
throw new BrunoError('Import collection failed: ' + err.message);

View File

@@ -45,12 +45,21 @@ const builder = (yargs) => {
describe: 'Skip SSL certificate verification when fetching from URLs',
default: false
})
.option('group-by', {
alias: 'g',
describe: 'How to group the imported requests: "tags" groups by OpenAPI tags, "path" groups by URL path structure',
type: 'string',
choices: ['tags', 'path'],
default: 'tags'
})
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --collection-name "My API"')
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -n "My API"')
.example('$0 import openapi --source https://example.com/api-spec.json --output ~/Desktop --collection-name "Remote API"')
.example('$0 import openapi --source https://self-signed.example.com/api.json --insecure --output ~/Desktop')
.example('$0 import openapi --source api.yml --output-file ~/Desktop/my-collection.json --collection-name "My API"')
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"');
.example('$0 import openapi -s api.yml -f ~/Desktop/my-collection.json -n "My API"')
.example('$0 import openapi --source api.yml --output ~/Desktop/my-collection --group-by path')
.example('$0 import openapi -s api.yml -o ~/Desktop/my-collection -g tags');
};
const isUrl = (str) => {
@@ -132,7 +141,7 @@ const readOpenApiFile = async (source, options = {}) => {
const handler = async (argv) => {
try {
const { type, source, output, outputFile, collectionName, insecure } = argv;
const { type, source, output, outputFile, collectionName, insecure, groupBy } = argv;
if (!type || type !== 'openapi') {
console.error(chalk.red('Only OpenAPI import is supported currently'));
@@ -161,7 +170,7 @@ const handler = async (argv) => {
console.log(chalk.yellow('Converting OpenAPI specification to Bruno format...'));
// Convert OpenAPI to Bruno format
let brunoCollection = openApiToBruno(openApiSpec);
let brunoCollection = openApiToBruno(openApiSpec, { groupBy });
// Override collection name if provided
if (collectionName) {

View File

@@ -394,6 +394,95 @@ const groupRequestsByTags = (requests) => {
return [groups, ungrouped];
};
const groupRequestsByPath = (requests) => {
const pathGroups = {};
// Group requests by their path segments
requests.forEach((request) => {
// Use original path for grouping to preserve {id} format
const pathToUse = request.originalPath || request.path;
const pathSegments = pathToUse.split('/').filter((segment) => segment !== '');
if (pathSegments.length === 0) {
// Handle root path or paths with only parameters
const groupName = 'Root';
if (!pathGroups[groupName]) {
pathGroups[groupName] = {
name: groupName,
requests: [],
subGroups: {}
};
}
pathGroups[groupName].requests.push(request);
return;
}
// Use the first segment as the main group
let groupName = pathSegments[0];
if (!pathGroups[groupName]) {
pathGroups[groupName] = {
name: groupName,
requests: [],
subGroups: {}
};
}
// If there's only one meaningful segment, add to main group
if (pathSegments.length <= 1) {
pathGroups[groupName].requests.push(request);
} else {
// For deeper paths, create sub-groups
let currentGroup = pathGroups[groupName];
for (let i = 1; i < pathSegments.length; i++) {
let subGroupName = pathSegments[i];
if (!currentGroup.subGroups[subGroupName]) {
currentGroup.subGroups[subGroupName] = {
name: subGroupName,
requests: [],
subGroups: {}
};
}
currentGroup = currentGroup.subGroups[subGroupName];
}
currentGroup.requests.push(request);
}
});
// Convert the nested structure to Bruno folder format
const buildFolderStructure = (group) => {
// Create a new usedNames set for each folder/subfolder scope
const localUsedNames = new Set();
const items = group.requests.map((req) => transformOpenapiRequestItem(req, localUsedNames));
// Add sub-folders
const subFolders = [];
Object.values(group.subGroups).forEach((subGroup) => {
const subFolderItems = buildFolderStructure(subGroup);
if (subFolderItems.length > 0) {
subFolders.push({
uid: uuid(),
name: subGroup.name,
type: 'folder',
items: subFolderItems
});
}
});
return [...items, ...subFolders];
};
const folders = Object.values(pathGroups).map((group) => ({
uid: uuid(),
name: group.name,
type: 'folder',
items: buildFolderStructure(group)
}));
return folders;
};
const getDefaultUrl = (serverObject) => {
let url = serverObject.url;
if (serverObject.variables) {
@@ -439,9 +528,8 @@ const openAPIRuntimeExpressionToScript = (expression) => {
return expression;
};
export const parseOpenApiCollection = (data) => {
export const parseOpenApiCollection = (data, options = {}) => {
const usedNames = new Set();
const brunoCollection = {
name: '',
uid: uuid(),
@@ -504,9 +592,10 @@ export const parseOpenApiCollection = (data) => {
return {
method: method,
path: path.replace(/{([^}]+)}/g, ':$1'), // Replace placeholders enclosed in curly braces with colons
originalPath: path, // Keep original path for grouping
operationObject: operationObject,
global: {
server: '{{baseUrl}}',
server: '{{baseUrl}}',
security: securityConfig
}
};
@@ -514,6 +603,13 @@ export const parseOpenApiCollection = (data) => {
})
.reduce((acc, val) => acc.concat(val), []); // flatten
// Support both tag-based and path-based grouping
const groupingType = options.groupBy || 'tags';
if (groupingType === 'path') {
brunoCollection.items = groupRequestsByPath(allRequests);
} else {
// Default tag-based grouping
let [groups, ungroupedRequests] = groupRequestsByTags(allRequests);
let brunoFolders = groups.map((group) => {
return {
@@ -535,13 +631,14 @@ export const parseOpenApiCollection = (data) => {
name: group.name
}
},
items: group.requests.map(req => transformOpenapiRequestItem(req, usedNames)),
items: group.requests.map((req) => transformOpenapiRequestItem(req, usedNames))
};
});
let ungroupedItems = ungroupedRequests.map(req => transformOpenapiRequestItem(req, usedNames));
let ungroupedItems = ungroupedRequests.map((req) => transformOpenapiRequestItem(req, usedNames));
let brunoCollectionItems = brunoFolders.concat(ungroupedItems);
brunoCollection.items = brunoCollectionItems;
}
// Determine collection-level authentication based on global security requirements
const buildCollectionAuth = (scheme) => {
@@ -648,13 +745,13 @@ export const parseOpenApiCollection = (data) => {
}
};
export const openApiToBruno = (openApiSpecification) => {
export const openApiToBruno = (openApiSpecification, options = {}) => {
try {
if(typeof openApiSpecification !== 'object') {
openApiSpecification = jsyaml.load(openApiSpecification);
}
const collection = parseOpenApiCollection(openApiSpecification);
const collection = parseOpenApiCollection(openApiSpecification, options);
const transformedCollection = transformItemsInCollection(collection);
const hydratedCollection = hydrateSeqInCollection(transformedCollection);
const validatedCollection = validateSchema(hydratedCollection);

View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
const openApiSpec = {
openapi: '3.0.0',
info: { title: 'Parameter API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
paths: {
'/{id}': {
get: {
summary: 'Get by ID',
operationId: 'getById',
responses: { 200: { description: 'OK' } }
}
},
'/{id}/{subId}': {
get: {
summary: 'Get by ID and sub ID',
operationId: 'getByIdAndSubId',
responses: { 200: { description: 'OK' } }
}
}
}
};
describe('openapi-import-grouping', () => {
it('should handle path based grouping', () => {
const result = openApiToBruno(openApiSpec, { groupBy: 'path' });
// Should have one folder containing both requests
expect(result.items).toHaveLength(1);
const folder = result.items[0];
expect(folder.name).toBe('{id}');
expect(folder.type).toBe('folder');
// Folder should contain one request and one subfolder
expect(folder.items).toHaveLength(2);
const requests = folder.items.filter((item) => item.type === 'http-request');
const subfolders = folder.items.filter((item) => item.type === 'folder');
expect(requests).toHaveLength(1);
expect(subfolders).toHaveLength(1);
// Check request name
expect(requests[0].name).toBe('Get by ID');
expect(requests[0].request.url).toBe('{{baseUrl}}/:id');
// Check subfolder
expect(subfolders[0].name).toBe('{subId}');
expect(subfolders[0].type).toBe('folder');
expect(subfolders[0].items).toHaveLength(1);
expect(subfolders[0].items[0].name).toBe('Get by ID and sub ID');
expect(subfolders[0].items[0].request.url).toBe('{{baseUrl}}/:id/:subId');
});
it('should handle tag based grouping', () => {
const result = openApiToBruno(openApiSpec, { groupBy: 'tags' });
// With tags grouping, requests without tags should be ungrouped
expect(result.items).toHaveLength(2);
// Both should be individual requests (not in folders)
result.items.forEach((item) => {
expect(item.type).toBe('http-request');
});
// Check request names
const requestNames = result.items.map((req) => req.name);
expect(requestNames).toContain('Get by ID');
expect(requestNames).toContain('Get by ID and sub ID');
// Check request URLs
const urls = result.items.map((req) => req.request.url);
expect(urls).toContain('{{baseUrl}}/:id');
expect(urls).toContain('{{baseUrl}}/:id/:subId');
});
});

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from '@jest/globals';
import openApiToBruno from '../../../src/openapi/openapi-to-bruno';
describe('OpenAPI Path-Based Grouping - Duplicate Names', () => {
it('should not add suffixes to duplicate operation names in different folders', () => {
const openApiSpec = {
openapi: '3.0.0',
info: {
title: 'Duplicate Names Test API',
version: '1.0.0'
},
servers: [
{
url: 'https://api.example.com/v1'
}
],
paths: {
// Users folder - should have "Get User Details" operation
'/users/{id}': {
get: {
summary: 'Get User Details',
operationId: 'getUserDetails',
responses: {
200: {
description: 'User details'
}
}
}
},
// Products folder - should also have "Get User Details" operation (different context)
'/products/{id}/owner': {
get: {
summary: 'Get User Details',
operationId: 'getProductOwnerDetails',
responses: {
200: {
description: 'Product owner details'
}
}
}
},
// Orders folder - should also have "Get User Details" operation (different context)
'/orders/{orderId}/customer': {
get: {
summary: 'Get User Details',
operationId: 'getOrderCustomerDetails',
responses: {
200: {
description: 'Order customer details'
}
}
}
}
}
};
const result = openApiToBruno(openApiSpec, { groupBy: 'path' });
// Find the folders
const usersFolder = result.items.find((item) => item.name === 'users');
const productsFolder = result.items.find((item) => item.name === 'products');
const ordersFolder = result.items.find((item) => item.name === 'orders');
// Find requests in each folder
// Users folder: /users/{id} -> should have request directly in users folder
const usersIdFolder = usersFolder.items.find((item) => item.name === '{id}');
expect(usersIdFolder).toBeDefined();
const getUserDetailsRequest = usersIdFolder.items.find((item) => item.type === 'http-request');
// Products folder: /products/{id}/owner -> should have request in products/{id}/owner
const productsIdFolder = productsFolder.items.find((item) => item.name === '{id}');
expect(productsIdFolder).toBeDefined();
const productsOwnerFolder = productsIdFolder.items.find((item) => item.name === 'owner');
expect(productsOwnerFolder).toBeDefined();
const getProductOwnerRequest = productsOwnerFolder.items.find((item) => item.type === 'http-request');
// Orders folder: /orders/{orderId}/customer -> should have request in orders/{orderId}/customer
const ordersIdFolder = ordersFolder.items.find((item) => item.name === '{orderId}');
expect(ordersIdFolder).toBeDefined();
const ordersCustomerFolder = ordersIdFolder.items.find((item) => item.name === 'customer');
expect(ordersCustomerFolder).toBeDefined();
const getOrderCustomerRequest = ordersCustomerFolder.items.find((item) => item.type === 'http-request');
expect(getUserDetailsRequest).toBeDefined();
expect(getProductOwnerRequest).toBeDefined();
expect(getOrderCustomerRequest).toBeDefined();
// CRITICAL ASSERTIONS: Names should NOT have suffixes
// Each folder should have its own namespace, so duplicate names across folders should be allowed
// All requests should have clean names (NO suffixes like "(GET)" or "(1)")
expect(getUserDetailsRequest.name).toBe('Get User Details');
expect(getProductOwnerRequest.name).toBe('Get User Details');
expect(getOrderCustomerRequest.name).toBe('Get User Details');
});
});

View File

@@ -0,0 +1,212 @@
{
"openapi": "3.0.0",
"info": {
"title": "Simple Test API",
"version": "1.0.0",
"description": "A simple API for testing groupBy functionality"
},
"servers": [
{
"url": "https://api.example.com",
"description": "Example server"
}
],
"paths": {
"/users": {
"get": {
"summary": "Get all users",
"operationId": "getUsers",
"tags": ["users"],
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": { "type": "integer" },
"name": { "type": "string" }
}
}
}
}
}
}
}
},
"post": {
"summary": "Create user",
"operationId": "createUser",
"tags": ["users"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
},
"responses": {
"201": {
"description": "User created"
}
}
}
},
"/users/{id}": {
"get": {
"summary": "Get user by ID",
"operationId": "getUserById",
"tags": ["users"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "User details"
}
}
},
"put": {
"summary": "Update user",
"operationId": "updateUser",
"tags": ["users"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"name": { "type": "string" }
}
}
}
}
},
"responses": {
"200": {
"description": "User updated"
}
}
}
},
"/products": {
"get": {
"summary": "Get all products",
"operationId": "getProducts",
"tags": ["products"],
"responses": {
"200": {
"description": "List of products"
}
}
}
},
"/products/{id}": {
"get": {
"summary": "Get product by ID",
"operationId": "getProductById",
"tags": ["products"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Product details"
}
}
}
},
"/orders": {
"get": {
"summary": "Get all orders",
"operationId": "getOrders",
"tags": ["orders"],
"responses": {
"200": {
"description": "List of orders"
}
}
},
"post": {
"summary": "Create order",
"operationId": "createOrder",
"tags": ["orders"],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"userId": { "type": "integer" },
"productId": { "type": "integer" }
}
}
}
}
},
"responses": {
"201": {
"description": "Order created"
}
}
}
},
"/orders/{id}": {
"get": {
"summary": "Get order by ID",
"operationId": "getOrderById",
"tags": ["orders"],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "Order details"
}
}
}
}
}
}

View File

@@ -0,0 +1,78 @@
import { test, expect } from '../../../../playwright';
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
test.describe('OpenAPI Import GroupBy Tests', () => {
test('CLI: Import OpenAPI with tags grouping', async ({ createTmpDir }) => {
const outputDir = await createTmpDir('openapi-tags');
const jsonOutputPath = path.join(outputDir, 'petstore-tags.json');
// Run OpenAPI import with tags grouping using JSON output
const cliPath = path.resolve(__dirname, '../../../../packages/bruno-cli/bin/bru.js');
const specPath = path.resolve(__dirname, './fixtures/openapi.json');
const command = `node "${cliPath}" import openapi --source "${specPath}" --output-file "${jsonOutputPath}" --collection-name "Simple API (Tags)" --group-by tags`;
try {
execSync(command, { stdio: 'pipe' });
} catch (error) {
// Continue with test even if import fails
}
// Verify JSON file was created
expect(fs.existsSync(jsonOutputPath)).toBe(true);
// Read and verify collection structure
const jsonCollection = JSON.parse(fs.readFileSync(jsonOutputPath, 'utf8'));
expect(jsonCollection.name).toBe('Simple API (Tags)');
// Verify tags grouping creates folders by OpenAPI tags
const folders = jsonCollection.items.filter((item) => item.type === 'folder');
expect(folders.length).toBe(3);
const folderNames = folders.map((folder) => folder.name);
expect(folderNames).toContain('users');
expect(folderNames).toContain('products');
expect(folderNames).toContain('orders');
// Verify tags grouping doesn't create {id} folders
const hasIdFolders = folders.some((folder) => folder.items?.some((item) => item.name === '{id}'));
expect(hasIdFolders).toBe(false);
});
test('CLI: Import OpenAPI with path grouping', async ({ createTmpDir }) => {
const outputDir = await createTmpDir('openapi-path');
const jsonOutputPath = path.join(outputDir, 'petstore-path.json');
// Run OpenAPI import with path grouping using JSON output
const cliPath = path.resolve(__dirname, '../../../../packages/bruno-cli/bin/bru.js');
const specPath = path.resolve(__dirname, './fixtures/openapi.json');
const command = `node "${cliPath}" import openapi --source "${specPath}" --output-file "${jsonOutputPath}" --collection-name "Simple API (Path)" --group-by path`;
try {
execSync(command, { stdio: 'pipe' });
} catch (error) {
// Continue with test even if import fails
}
// Verify JSON file was created
expect(fs.existsSync(jsonOutputPath)).toBe(true);
// Read and verify collection structure
const jsonCollection = JSON.parse(fs.readFileSync(jsonOutputPath, 'utf8'));
expect(jsonCollection.name).toBe('Simple API (Path)');
// Verify path grouping creates folders by URL path structure
const folders = jsonCollection.items.filter((item) => item.type === 'folder');
expect(folders.length).toBe(3); // users, products, orders
const folderNames = folders.map((folder) => folder.name);
expect(folderNames).toContain('users');
expect(folderNames).toContain('products');
expect(folderNames).toContain('orders');
// Verify path grouping creates {id} folders for parameterized paths
const hasIdFolders = folders.some((folder) => folder.items?.some((item) => item.name === '{id}'));
expect(hasIdFolders).toBe(true);
});
});

View File

@@ -1,7 +1,13 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('OpenAPI Duplicate Names Handling', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('should handle duplicate operation names', async ({ page, createTmpDir }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-duplicate-operation-name.yaml');
@@ -18,6 +24,13 @@ test.describe('OpenAPI Duplicate Names Handling', () => {
// wait for the file processing to complete
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// verify that the import settings modal appears
const settingsModal = page.getByTestId('import-settings-modal');
await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings');
// click the Import button in the settings modal footer
await settingsModal.getByRole('button', { name: 'Import' }).click();
// verify that the collection location modal appears (OpenAPI files go directly to location modal)
const locationModal = page.getByTestId('import-collection-location-modal');
// verify the collection name is correctly parsed despite duplicate operation names
@@ -37,14 +50,5 @@ test.describe('OpenAPI Duplicate Names Handling', () => {
// verify that all 3 requests were imported correctly despite duplicate operation names
await expect(page.locator('#collection-duplicate-test-collection .collection-item-name')).toHaveCount(3);
// cleanup: close the collection
await page
.locator('.collection-name')
.filter({ has: page.locator('#sidebar-collection-name:has-text("Duplicate Test Collection")') })
.locator('.collection-actions')
.click();
await page.locator('.dropdown-item').getByText('Close').click();
await page.getByRole('button', { name: 'Close' }).click();
});
});

View File

@@ -0,0 +1,76 @@
{
"openapi": "3.0.0",
"info": {
"title": "Path Grouping Test API",
"description": "API for testing path-based folder grouping",
"version": "1.0.0"
},
"servers": [
{
"url": "https://api.example.com",
"description": "Test server"
}
],
"paths": {
"/users": {
"get": {
"summary": "List users",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/users/{id}": {
"get": {
"summary": "Get user by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/products": {
"get": {
"summary": "List products",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/products/{id}": {
"get": {
"summary": "Get product by ID",
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Success"
}
}
}
}
}
}

View File

@@ -17,6 +17,13 @@ test.describe('Import OpenAPI v3 JSON Collection', () => {
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// verify that the import settings modal appears
const settingsModal = page.getByTestId('import-settings-modal');
await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings');
// click the Import button in the settings modal footer
await settingsModal.getByRole('button', { name: 'Import' }).click();
// Verify that the Import Collection modal is displayed (for location selection)
const locationModal = page.getByRole('dialog');
await expect(locationModal.locator('.bruno-modal-header-title')).toContainText('Import Collection');

View File

@@ -14,6 +14,13 @@ test.describe('Import OpenAPI v3 YAML Collection', () => {
await page.setInputFiles('input[type="file"]', openApiFile);
// verify that the import settings modal appears
const settingsModal = page.getByTestId('import-settings-modal');
await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings');
// click the Import button in the settings modal footer
await settingsModal.getByRole('button', { name: 'Import' }).click();
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });

View File

@@ -1,7 +1,13 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('OpenAPI Newline Handling', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('should handle operation names with newlines', async ({ page, createTmpDir }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-newline-in-operation-name.yaml');
@@ -15,6 +21,13 @@ test.describe('OpenAPI Newline Handling', () => {
// upload the OpenAPI file with problematic operation names
await page.setInputFiles('input[type="file"]', openApiFile);
// verify that the import settings modal appears
const settingsModal = page.getByTestId('import-settings-modal');
await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings');
// click the Import button in the settings modal footer
await settingsModal.getByRole('button', { name: 'Import' }).click();
// wait for the file processing to complete
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
@@ -37,14 +50,5 @@ test.describe('OpenAPI Newline Handling', () => {
// verify that all requests were imported correctly despite newlines in operation names
// the parser should clean up the operation names and create valid request names
await expect(page.locator('#collection-newline-test-collection .collection-item-name')).toHaveCount(2);
// cleanup: close the collection
await page
.locator('.collection-name')
.filter({ has: page.locator('#sidebar-collection-name:has-text("Newline Test Collection")') })
.locator('.collection-actions')
.click();
await page.locator('.dropdown-item').getByText('Close').click();
await page.getByRole('button', { name: 'Close' }).click();
});
});

View File

@@ -0,0 +1,68 @@
import { test, expect } from '../../../playwright';
import * as path from 'path';
import { closeAllCollections } from '../../utils/page';
test.describe('OpenAPI Path-Based Grouping', () => {
test.afterEach(async ({ page }) => {
// cleanup: close all collections
await closeAllCollections(page);
});
test('should import with path-based folder grouping', async ({ page, createTmpDir }) => {
const openApiFile = path.resolve(__dirname, 'fixtures', 'openapi-path-grouping.json');
// Start the import process
await page.getByRole('button', { name: 'Import Collection' }).click();
// Wait for import collection modal to be ready
const importModal = page.getByTestId('import-collection-modal');
await importModal.waitFor({ state: 'visible' });
// Upload the OpenAPI file
await page.setInputFiles('input[type="file"]', openApiFile);
// Wait for the loader to disappear
await page.locator('#import-collection-loader').waitFor({ state: 'hidden' });
// Verify that the import settings modal appears
const settingsModal = page.getByTestId('import-settings-modal');
await expect(settingsModal.locator('.bruno-modal-header-title')).toContainText('OpenAPI Import Settings');
// Select path-based grouping from the dropdown
await settingsModal.getByTestId('grouping-dropdown').click();
// Wait for dropdown options to be visible (they might be rendered outside the modal)
await page.getByTestId('grouping-option-path').waitFor({ state: 'visible' });
await page.getByTestId('grouping-option-path').click();
// Now import the collection with path-based grouping
await settingsModal.getByRole('button', { name: 'Import' }).click();
// Verify that the collection location modal appears
const locationModal = page.getByTestId('import-collection-location-modal');
await expect(locationModal.getByText('Path Grouping Test API')).toBeVisible();
// Select a location and import
await page.locator('#collection-location').fill(await createTmpDir('path-grouping-test'));
await page.getByRole('button', { name: 'Import', exact: true }).click();
// Verify the collection was imported successfully
await expect(page.locator('#sidebar-collection-name').getByText('Path Grouping Test API')).toBeVisible();
// Configure the collection settings
await page.locator('#sidebar-collection-name').getByText('Path Grouping Test API').click();
await page.getByLabel('Safe Mode').check();
await page.getByRole('button', { name: 'Save' }).click();
// Verify path-based folder structure was created
// Should have 'users' and 'products' folders
await expect(page.locator('.collection-item-name').getByText('users')).toBeVisible();
await expect(page.locator('.collection-item-name').getByText('products')).toBeVisible();
// Expand the products folder to check for nested structure
await page.locator('.collection-item-name').getByText('products').click();
// Verify that the products folder contains the {id} subfolder
await expect(page.locator('.collection-item-name').getByText('{id}')).toBeVisible();
});
});