diff --git a/packages/bruno-converters/src/common/index.js b/packages/bruno-converters/src/common/index.js index 223e90337..db5972890 100644 --- a/packages/bruno-converters/src/common/index.js +++ b/packages/bruno-converters/src/common/index.js @@ -42,6 +42,68 @@ export const uuid = () => { return customNanoId(); }; +/** + * Sanitizes a tag name for BRU format compatibility. + * BRU format only supports tag names containing alphanumeric characters, + * hyphens (-), and underscores (_). Spaces are replaced with underscores. + * + * @param {string} tag - The tag to sanitize + * @param {Object} options - Sanitization options + * @param {string} options.collectionFormat - The collection format ('yml' for OpenCollection YAML) + * @returns {string|null} - The sanitized tag, or null if the result is empty + */ +export const sanitizeTag = (tag, options = {}) => { + const typeofTag = typeof tag; + if (!tag || !['string', 'object'].includes(typeofTag)) { + return null; + } + + let usableTagString = typeof tag == 'string' ? tag : 'name' in tag ? tag.name : ''; + + let sanitized = usableTagString.trim(); + + // BRU format only supports alphanumeric, hyphens, and underscores in tags + // The BRU grammar defines listitem as: (alnum | "_" | "-")+ + // Spaces are NOT allowed, so we replace them with underscores + + // Replace spaces with underscores first + sanitized = sanitized.replace(/\s+/g, '_'); + + // Replace any character that's NOT alphanumeric, hyphen, or underscore with underscore + sanitized = sanitized.replace(/[^\p{L}\p{N}\-_]/gu, '_'); + + // Collapse multiple consecutive underscores into one + sanitized = sanitized.replace(/_+/g, '_'); + + // Remove leading characters that aren't alphanumeric + sanitized = sanitized.replace(/^[^\p{L}\p{N}]+/gu, ''); + + // Remove trailing characters that aren't alphanumeric + sanitized = sanitized.replace(/[^\p{L}\p{N}]+$/gu, ''); + + // Return null if the result is empty + return sanitized || null; +}; + +/** + * Sanitizes an array of tags, removing duplicates and null values. + * + * @param {string[]} tags - Array of tags to sanitize + * @param {Object} options - Sanitization options + * @returns {string[]} - Array of unique sanitized tags + */ +export const sanitizeTags = (tags, options = {}) => { + if (!Array.isArray(tags)) { + return []; + } + + return [...new Set( + tags + .map((tag) => sanitizeTag(tag, options)) + .filter((tag) => tag !== null) + )]; +}; + export const validateSchema = (collection = {}) => { try { collectionSchema.validateSync(collection); diff --git a/packages/bruno-converters/src/openapi/openapi-to-bruno.js b/packages/bruno-converters/src/openapi/openapi-to-bruno.js index be9deb010..7519a04a5 100644 --- a/packages/bruno-converters/src/openapi/openapi-to-bruno.js +++ b/packages/bruno-converters/src/openapi/openapi-to-bruno.js @@ -1,7 +1,7 @@ import each from 'lodash/each'; import get from 'lodash/get'; import jsyaml from 'js-yaml'; -import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid } from '../common'; +import { validateSchema, transformItemsInCollection, hydrateSeqInCollection, uuid, sanitizeTag, sanitizeTags } from '../common'; // Content type patterns for matching MIME type variants // These patterns handle structured types with many variants (e.g., application/ld+json, application/vnd.api+json) @@ -533,15 +533,7 @@ const transformOpenapiRequestItem = (request, usedNames = new Set(), options = { uid: uuid(), name: operationName, type: 'http-request', - tags: [...new Set( - (request.operationObject.tags || []).map((tag) => { - let sanitized = tag.trim(); - if (options.collectionFormat !== 'yml') { - sanitized = sanitized.replace(/\s+/g, '_'); - } - return sanitized; - }).filter((tag) => tag.trim()) - )], + tags: sanitizeTags(request.operationObject.tags || [], options), request: { docs: _operationObject.description, url: ensureUrl(request.global.server + path), @@ -1032,13 +1024,13 @@ const resolveRefs = (spec, components = spec?.components, cache = new Map()) => return resolved; }; -const groupRequestsByTags = (requests) => { +const groupRequestsByTags = (requests, options = {}) => { let _groups = {}; let ungrouped = []; each(requests, (request) => { let tags = request.operationObject.tags || []; if (tags.length > 0) { - let tag = tags[0].trim(); // take first tag and trim whitespace + let tag = sanitizeTag(tags[0].trim()); // take first tag, trim whitespace, and sanitize if (tag) { if (!_groups[tag]) { @@ -1274,7 +1266,7 @@ export const parseOpenApiCollection = (data, options = {}) => { brunoCollection.items = groupRequestsByPath(allRequests, options); } else { // Default tag-based grouping - let [groups, ungroupedRequests] = groupRequestsByTags(allRequests); + let [groups, ungroupedRequests] = groupRequestsByTags(allRequests, options); let brunoFolders = groups.map((group) => { return { uid: uuid(), diff --git a/packages/bruno-converters/tests/common/sanitizeTag.spec.js b/packages/bruno-converters/tests/common/sanitizeTag.spec.js new file mode 100644 index 000000000..b6d64dcf9 --- /dev/null +++ b/packages/bruno-converters/tests/common/sanitizeTag.spec.js @@ -0,0 +1,181 @@ +import { describe, it, expect } from '@jest/globals'; +import { sanitizeTag, sanitizeTags } from '../../src/common/index.js'; + +describe('sanitizeTag', () => { + describe('basic functionality', () => { + it('should return null for null input', () => { + expect(sanitizeTag(null)).toBeNull(); + }); + + it('should return null for undefined input', () => { + expect(sanitizeTag(undefined)).toBeNull(); + }); + + it('should return null for non-string input', () => { + expect(sanitizeTag(123)).toBeNull(); + expect(sanitizeTag({})).toBeNull(); + expect(sanitizeTag([])).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(sanitizeTag('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(sanitizeTag(' ')).toBeNull(); + expect(sanitizeTag('\t\n')).toBeNull(); + }); + }); + + describe('valid tags', () => { + it('should preserve alphanumeric tags', () => { + expect(sanitizeTag('valid')).toBe('valid'); + expect(sanitizeTag('ValidTag')).toBe('ValidTag'); + expect(sanitizeTag('tag123')).toBe('tag123'); + }); + + it('should preserve tags with hyphens', () => { + expect(sanitizeTag('valid-tag')).toBe('valid-tag'); + expect(sanitizeTag('my-api-endpoint')).toBe('my-api-endpoint'); + }); + + it('should preserve tags with underscores', () => { + expect(sanitizeTag('valid_tag')).toBe('valid_tag'); + expect(sanitizeTag('my_api_endpoint')).toBe('my_api_endpoint'); + }); + + it('should replace spaces with underscores', () => { + expect(sanitizeTag('User Management')).toBe('User_Management'); + expect(sanitizeTag('API v1')).toBe('API_v1'); + }); + + it('should preserve tags with mixed valid characters (spaces become underscores)', () => { + expect(sanitizeTag('valid-tag_name')).toBe('valid-tag_name'); + expect(sanitizeTag('API v1-endpoint')).toBe('API_v1-endpoint'); + expect(sanitizeTag('User Management API')).toBe('User_Management_API'); + }); + }); + + describe('space handling', () => { + it('should replace spaces with underscores in the middle of tags', () => { + expect(sanitizeTag('User Management')).toBe('User_Management'); + expect(sanitizeTag('API v1')).toBe('API_v1'); + }); + + it('should collapse multiple spaces into a single underscore', () => { + expect(sanitizeTag('User Management')).toBe('User_Management'); + expect(sanitizeTag('API v1')).toBe('API_v1'); + }); + + it('should trim leading and trailing spaces', () => { + expect(sanitizeTag(' tag ')).toBe('tag'); + expect(sanitizeTag('\ttag\n')).toBe('tag'); + }); + + it('should remove leading/trailing spaces and replace internal spaces with underscores', () => { + expect(sanitizeTag(' User Management ')).toBe('User_Management'); + }); + }); + + describe('special character handling', () => { + it('should replace dots with underscores', () => { + expect(sanitizeTag('api.v1')).toBe('api_v1'); + expect(sanitizeTag('api.v1.0')).toBe('api_v1_0'); + }); + + it('should replace colons with underscores', () => { + expect(sanitizeTag('api:v1')).toBe('api_v1'); + }); + + it('should replace slashes with underscores', () => { + expect(sanitizeTag('api/v1')).toBe('api_v1'); + expect(sanitizeTag('api/v1/users')).toBe('api_v1_users'); + }); + + it('should replace parentheses with underscores', () => { + // 'API (v1)' has space before parenthesis, both become underscores + expect(sanitizeTag('API (v1)')).toBe('API_v1'); + // 'API(v1)' has no space, so it becomes 'API_v1' + expect(sanitizeTag('API(v1)')).toBe('API_v1'); + }); + + it('should replace multiple special characters', () => { + // 'API v1.0 (beta)' - spaces, dots, parentheses all become underscores + // Result: 'API_v1_0_beta' (collapsed to single underscores) + expect(sanitizeTag('API v1.0 (beta)')).toBe('API_v1_0_beta'); + expect(sanitizeTag('api.v1:beta')).toBe('api_v1_beta'); + }); + + it('should handle special characters at start and end', () => { + expect(sanitizeTag('.api')).toBe('api'); + expect(sanitizeTag('api.')).toBe('api'); + expect(sanitizeTag('-api')).toBe('api'); + expect(sanitizeTag('api-')).toBe('api'); + expect(sanitizeTag('_api')).toBe('api'); + expect(sanitizeTag('api_')).toBe('api'); + expect(sanitizeTag(' api')).toBe('api'); + expect(sanitizeTag('api ')).toBe('api'); + }); + + it('should return null when result is only special characters', () => { + expect(sanitizeTag('...')).toBeNull(); + expect(sanitizeTag('---')).toBeNull(); + expect(sanitizeTag('___')).toBeNull(); + expect(sanitizeTag('.-_')).toBeNull(); + }); + }); + + describe('options handling', () => { + it('should ignore collectionFormat option and always sanitize', () => { + // The collectionFormat option is no longer used - always sanitize + // Spaces are replaced with underscores for BRU format compatibility + expect(sanitizeTag('User Management', { collectionFormat: 'yml' })).toBe('User_Management'); + expect(sanitizeTag('api.v1', { collectionFormat: 'yml' })).toBe('api_v1'); + // 'API (v1)' becomes 'API_v1' (space and parentheses become underscores) + expect(sanitizeTag('API (v1)', { collectionFormat: 'yml' })).toBe('API_v1'); + }); + }); +}); + +describe('sanitizeTags', () => { + it('should return empty array for null input', () => { + expect(sanitizeTags(null)).toEqual([]); + }); + + it('should return empty array for undefined input', () => { + expect(sanitizeTags(undefined)).toEqual([]); + }); + + it('should return empty array for non-array input', () => { + expect(sanitizeTags('string')).toEqual([]); + expect(sanitizeTags(123)).toEqual([]); + expect(sanitizeTags({})).toEqual([]); + }); + + it('should return empty array for empty array input', () => { + expect(sanitizeTags([])).toEqual([]); + }); + + it('should sanitize all tags in array', () => { + // Spaces are replaced with underscores + expect(sanitizeTags(['User Management', 'API v1'])).toEqual(['User_Management', 'API_v1']); + }); + + it('should remove null values from result', () => { + expect(sanitizeTags(['valid', '...', 'also-valid'])).toEqual(['valid', 'also-valid']); + }); + + it('should remove duplicates from result', () => { + expect(sanitizeTags(['User Management', 'User Management'])).toEqual(['User_Management']); + expect(sanitizeTags(['api.v1', 'api_v1'])).toEqual(['api_v1']); + }); + + it('should preserve order of first occurrence', () => { + expect(sanitizeTags(['tag1', 'tag2', 'tag1'])).toEqual(['tag1', 'tag2']); + }); + + it('should handle mixed valid and invalid tags', () => { + // Spaces are replaced with underscores + expect(sanitizeTags(['valid-tag', 'invalid.tag', 'another valid'])).toEqual(['valid-tag', 'invalid_tag', 'another_valid']); + }); +}); diff --git a/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-tags.spec.js b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-tags.spec.js new file mode 100644 index 000000000..513a7976a --- /dev/null +++ b/packages/bruno-converters/tests/openapi/openapi-to-bruno/openapi-tags.spec.js @@ -0,0 +1,395 @@ +import { describe, it, expect } from '@jest/globals'; +import openApiToBruno from '../../../src/openapi/openapi-to-bruno'; + +/** + * Helper function to find a request by name in the collection. + * Searches recursively through folders since requests with tags + * are grouped into folders. + */ +const findRequestByName = (items, name) => { + for (const item of items) { + if (item.type === 'http-request' && item.name === name) { + return item; + } + if (item.type === 'folder' && item.items) { + const found = findRequestByName(item.items, name); + if (found) return found; + } + } + return undefined; +}; + +/** + * Helper function to find a folder by name in the collection. + */ +const findFolderByName = (items, name) => { + for (const item of items) { + if (item.type === 'folder' && item.name === name) { + return item; + } + if (item.type === 'folder' && item.items) { + const found = findFolderByName(item.items, name); + if (found) return found; + } + } + return undefined; +}; + +describe('OpenAPI Import - Tag Sanitization', () => { + it('should replace spaces with underscores in tags', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['User Management'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + // Spaces are replaced with underscores for BRU format compatibility + expect(request.tags).toEqual(['User_Management']); + }); + + it('should sanitize tags with dots', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['api.v1', 'user.service'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + // Dots should be replaced with underscores + expect(request.tags).toEqual(['api_v1', 'user_service']); + }); + + it('should sanitize tags with special characters', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['API (v1)', 'user-service:v2'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + // Parentheses, colons, and spaces should be replaced with underscores + // 'API (v1)' becomes 'API_v1' (space and parentheses become underscores, collapsed) + expect(request.tags).toEqual(['API_v1', 'user-service_v2']); + }); + + it('should preserve valid tags', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['users', 'api-v1', 'user_service'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + expect(request.tags).toEqual(['users', 'api-v1', 'user_service']); + }); + + it('should handle empty tags array', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: [], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + expect(request.tags).toEqual([]); + }); + + it('should handle missing tags property', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + expect(request.tags).toEqual([]); + }); + + it('should remove duplicate tags after sanitization', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['User Management', 'User Management', 'user-management'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + // 'User Management' becomes 'User_Management', which is different from 'user-management' + expect(request.tags).toEqual(['User_Management', 'user-management']); + }); + + it('should filter out tags that become empty after sanitization', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['...', 'valid-tag', '---'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const request = findRequestByName(result.items, 'Get users'); + expect(request).toBeDefined(); + expect(request.tags).toEqual(['valid-tag']); + }); + + it('should use sanitized tag names for folder grouping', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['User Management'], + responses: { + 200: { + description: 'Success' + } + } + } + }, + '/posts': { + get: { + operationId: 'getPosts', + summary: 'Get posts', + tags: ['User Management'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + // Find the folder created from the tag - spaces replaced with underscores + const folder = findFolderByName(result.items, 'User_Management'); + expect(folder).toBeDefined(); + expect(folder.name).toBe('User_Management'); + expect(folder.items).toHaveLength(2); + }); + + it('should sanitize folder names from tags with dots', () => { + const openApiSpec = { + openapi: '3.0.0', + info: { + title: 'Test API', + version: '1.0.0' + }, + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['api.v1'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + // Find the folder created from the tag - dots should be replaced + const folder = findFolderByName(result.items, 'api_v1'); + expect(folder).toBeDefined(); + expect(folder.name).toBe('api_v1'); + }); + + it('should handle utf characters as well', () => { + const openApiSpec = { + openapi: '3.0.1', + info: { + title: 'CBC-MODEL3D-API', + description: 'POWER BY WARE4U', + termsOfService: 'http://swagger.io/terms/', + contact: { + name: '陈洪', + email: 'sendreams@hotmail.com' + }, + license: { + name: 'Apache 2.0', + url: 'http://springdoc.org' + }, + version: '1.0.0' + }, + tags: [ + { + name: '模型管理', + description: '发布和管理3d模型' + }, + { + name: '模型集市', + description: '模型查询、评价、下单等' + } + ], + paths: { + '/users': { + get: { + operationId: 'getUsers', + summary: 'Get users', + tags: ['模型管理'], + responses: { + 200: { + description: 'Success' + } + } + } + } + } + }; + + const result = openApiToBruno(JSON.stringify(openApiSpec)); + const folder = findFolderByName(result.items, '模型管理'); + expect(folder).toBeDefined(); + }); +});