fix(import): preserve special chars in OpenAPI tag/folder names for yml collections (BRU-3175) (#8123)

The OpenAPI importer's tag-sanitization step rewrote every non-alphanumeric
character to `_` unconditionally, regardless of target collection format.
That's correct for `.bru` (whose grammar restricts list items to
`(alnum | "_" | "-")+`) but wrong for the opencollection (yml) target,
whose Tag schema imposes no character restriction. As a result:

  `Pets & Dogs`  →  `Pets_Dogs`
  `R&D`          →  `R_D`
  `&`            →  dropped

This fix makes `sanitizeTag` branch on `options.collectionFormat`:
- `yml` → trim only, preserve verbatim
- `bru` (or default) → keep existing BRU-grammar sanitization

Three call sites updated:

1. `packages/bruno-converters/src/common/index.js` — `sanitizeTag`
   honors `options.collectionFormat`.
2. `packages/bruno-converters/src/openapi/openapi-common.js` —
   `groupRequestsByTags` now accepts + threads `options` so the
   folder-grouping path also respects format.
3. `packages/bruno-schema/src/collections/index.js` — `itemSchema.tags`
   regex relaxed to `Yup.string().min(1)` to match the OpenCollection
   `Tag = string` spec; old regex enforced BRU grammar on the in-memory
   collection shape and rejected our newly-preserved tags downstream.

Cross-platform safety: tags carrying FS-dangerous characters (`/`, `\`,
control chars, Windows-forbidden chars, trailing dot/space) are still
made safe on disk by Bruno's existing `sanitizeName` (in
`packages/bruno-electron/src/utils/filesystem.js`). UI sidebar reads
`info.name` from `folder.yml`, so user-facing label preserves the
verbatim tag while the on-disk path stays portable. Behavior verified
identical on macOS / Linux / Windows for the AC examples + common
inputs. Windows-reserved tag names (`CON`, `PRN`, etc.) and
filesystem-inherent issues (case-sensitivity, length limits) are
pre-existing gaps in Bruno's writer, not in scope here.

Tests:
- `tests/common/sanitizeTag.spec.js` — replaced the old "always sanitize"
  test (which locked in the buggy behavior) with a `collectionFormat`
  branch covering yml-preservation + bru-strict for the ticket's 3
  examples plus dot/parens/whitespace edge cases.
- `tests/openapi/openapi-to-bruno/openapi-tags.spec.js` — added a
  `describe('yml tag preservation')` block exercising the full importer
  pipeline (request tags + folder grouping) on the 3 AC examples.
- `bruno-schema/src/collections/itemSchema.spec.js` — updated the
  validation test to reflect the relaxed schema; verified that previously
  rejected strings (`Pets & Dogs`, `R&D`, `&`, emoji, etc.) now pass and
  empty strings still fail.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sundram
2026-05-28 15:03:54 +05:30
committed by GitHub
parent 413697cbe7
commit 4ee9a75465
6 changed files with 104 additions and 30 deletions

View File

@@ -60,11 +60,18 @@ export const sanitizeTag = (tag, options = {}) => {
let usableTagString = typeof tag == 'string' ? tag : 'name' in tag ? tag.name : '';
let sanitized = usableTagString.trim();
let trimmed = usableTagString.trim();
// OpenCollection (yml) schema imposes no character restriction on tags.
// Preserve the source value verbatim so folder names round-trip (BRU-3175).
if (options.collectionFormat === 'yml') {
return trimmed || null;
}
// 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
let sanitized = trimmed;
// Replace spaces with underscores first
sanitized = sanitized.replace(/\s+/g, '_');

View File

@@ -499,15 +499,16 @@ export const createBrunoExample = ({ brunoRequestItem, exampleValue, exampleName
/**
* Groups requests by their first tag
* @param {Array} requests - Array of parsed request objects
* @param {Object} options - Sanitization options (forwarded to sanitizeTag)
* @returns {Array} Tuple of [tagGroups, ungroupedRequests]
*/
export const groupRequestsByTags = (requests) => {
export const groupRequestsByTags = (requests, options = {}) => {
let _groups = {};
let ungrouped = [];
each(requests, (request) => {
let tags = request.operationObject.tags || [];
if (tags.length > 0) {
let tag = sanitizeTag(tags[0].trim()); // take first tag, trim whitespace, and sanitize
let tag = sanitizeTag(tags[0].trim(), options); // take first tag, trim whitespace, and sanitize
if (tag) {
if (!_groups[tag]) {

View File

@@ -125,14 +125,50 @@ describe('sanitizeTag', () => {
});
});
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('options.collectionFormat handling (BRU-3175)', () => {
describe('yml (OpenCollection) — preserves tag verbatim', () => {
it('preserves ampersand and spaces', () => {
expect(sanitizeTag('Pets & Dogs', { collectionFormat: 'yml' })).toBe('Pets & Dogs');
expect(sanitizeTag('R&D', { collectionFormat: 'yml' })).toBe('R&D');
});
it('preserves a single special character', () => {
expect(sanitizeTag('&', { collectionFormat: 'yml' })).toBe('&');
});
it('preserves dots, parentheses and other punctuation', () => {
expect(sanitizeTag('api.v1', { collectionFormat: 'yml' })).toBe('api.v1');
expect(sanitizeTag('API (v1)', { collectionFormat: 'yml' })).toBe('API (v1)');
});
it('trims surrounding whitespace but keeps inner whitespace verbatim', () => {
expect(sanitizeTag(' Pets & Dogs ', { collectionFormat: 'yml' })).toBe('Pets & Dogs');
});
it('returns null for whitespace-only input', () => {
expect(sanitizeTag(' ', { collectionFormat: 'yml' })).toBeNull();
});
it('reads .name from tag-object input', () => {
expect(sanitizeTag({ name: 'R&D' }, { collectionFormat: 'yml' })).toBe('R&D');
});
});
describe('bru (legacy) — keeps existing strict sanitization', () => {
it('rewrites ampersand and spaces to underscores', () => {
expect(sanitizeTag('Pets & Dogs', { collectionFormat: 'bru' })).toBe('Pets_Dogs');
expect(sanitizeTag('R&D', { collectionFormat: 'bru' })).toBe('R_D');
});
it('drops a tag of only special characters', () => {
expect(sanitizeTag('&', { collectionFormat: 'bru' })).toBeNull();
});
it('matches default behavior when collectionFormat is omitted', () => {
expect(sanitizeTag('Pets & Dogs')).toBe('Pets_Dogs');
expect(sanitizeTag('R&D')).toBe('R_D');
expect(sanitizeTag('&')).toBeNull();
});
});
});
});

View File

@@ -393,3 +393,46 @@ describe('OpenAPI Import - Tag Sanitization', () => {
expect(folder).toBeDefined();
});
});
describe('OpenAPI Import - yml (opencollection) tag preservation (BRU-3175)', () => {
const buildSpec = (tag) => ({
openapi: '3.0.0',
info: { title: 'Test API', version: '1.0.0' },
paths: {
'/x': {
get: {
operationId: 'getX',
summary: 'Get X',
tags: [tag],
responses: { 200: { description: 'OK' } }
}
}
}
});
it.each([
['Pets & Dogs', 'Pets & Dogs'],
['R&D', 'R&D'],
['&', '&'],
['API (v1)', 'API (v1)'],
['api.v1', 'api.v1']
])('preserves tag %p verbatim on request and folder for yml format', (sourceTag, expected) => {
const result = openApiToBruno(JSON.stringify(buildSpec(sourceTag)), { collectionFormat: 'yml' });
const request = findRequestByName(result.items, 'Get X');
expect(request).toBeDefined();
expect(request.tags).toEqual([expected]);
const folder = findFolderByName(result.items, expected);
expect(folder).toBeDefined();
});
it('keeps bru-format sanitization unchanged when collectionFormat is omitted', () => {
const result = openApiToBruno(JSON.stringify(buildSpec('Pets & Dogs')));
const request = findRequestByName(result.items, 'Get X');
expect(request.tags).toEqual(['Pets_Dogs']);
const folder = findFolderByName(result.items, 'Pets_Dogs');
expect(folder).toBeDefined();
});
});

View File

@@ -621,7 +621,7 @@ const itemSchema = Yup.object({
type: Yup.string().oneOf(['http-request', 'graphql-request', 'folder', 'js', 'grpc-request', 'ws-request']).required('type is required'),
seq: Yup.number().min(1),
name: Yup.string().min(1, 'name must be at least 1 character').required('name is required'),
tags: Yup.array().of(Yup.string().matches(/^[\p{L}\p{N}_-](?:[\p{L}\p{N}_\s-]*[\p{L}\p{N}_-])?$/u, 'tag must contain only letters, numbers, spaces, hyphens, or underscores')),
tags: Yup.array().of(Yup.string().min(1, 'tag must not be empty')),
request: Yup.mixed().when('type', {
is: (type) => type === 'grpc-request',
then: grpcRequestSchema.required('request is required when item-type is grpc-request'),

View File

@@ -15,38 +15,25 @@ describe('Item Schema Validation', () => {
expect(isValid).toBeTruthy();
});
it('item schema must validate tag regex rules', async () => {
it('item schema accepts arbitrary non-empty tag strings (opencollection allows any chars)', async () => {
const validItem = {
uid: uuid(),
name: 'A Folder',
type: 'folder',
tags: ['tag_1', 'Äiti-123 test']
tags: ['tag_1', 'Äiti-123 test', 'Pets & Dogs', 'R&D', '&', 'tag🔥name']
};
const isValid = await itemSchema.validate(validItem);
expect(isValid).toBeTruthy();
let invalidItem = {
const invalidItem = {
uid: uuid(),
name: 'A Folder',
type: 'folder',
tags: [' invalid-tag']
tags: ['']
};
await expect(itemSchema.validate(invalidItem)).rejects.toThrow(
'tag must contain only letters, numbers, spaces, hyphens, or underscores'
);
invalidItem = {
uid: uuid(),
name: 'A Folder',
type: 'folder',
tags: ['tag🔥name']
};
await expect(itemSchema.validate(invalidItem)).rejects.toThrow(
'tag must contain only letters, numbers, spaces, hyphens, or underscores'
);
await expect(itemSchema.validate(invalidItem)).rejects.toThrow('tag must not be empty');
});
it('item schema must throw an error if name is missing', async () => {