From 4ee9a7546510f4e7cc4a040b12b01ed5da265908 Mon Sep 17 00:00:00 2001 From: Sundram Date: Thu, 28 May 2026 15:03:54 +0530 Subject: [PATCH] fix(import): preserve special chars in OpenAPI tag/folder names for yml collections (BRU-3175) (#8123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- packages/bruno-converters/src/common/index.js | 9 +++- .../src/openapi/openapi-common.js | 5 +- .../tests/common/sanitizeTag.spec.js | 52 ++++++++++++++++--- .../openapi-to-bruno/openapi-tags.spec.js | 43 +++++++++++++++ .../bruno-schema/src/collections/index.js | 2 +- .../src/collections/itemSchema.spec.js | 23 ++------ 6 files changed, 104 insertions(+), 30 deletions(-) diff --git a/packages/bruno-converters/src/common/index.js b/packages/bruno-converters/src/common/index.js index 765533f10..dc0734935 100644 --- a/packages/bruno-converters/src/common/index.js +++ b/packages/bruno-converters/src/common/index.js @@ -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, '_'); diff --git a/packages/bruno-converters/src/openapi/openapi-common.js b/packages/bruno-converters/src/openapi/openapi-common.js index 61d270fe0..ac3ba1669 100644 --- a/packages/bruno-converters/src/openapi/openapi-common.js +++ b/packages/bruno-converters/src/openapi/openapi-common.js @@ -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]) { diff --git a/packages/bruno-converters/tests/common/sanitizeTag.spec.js b/packages/bruno-converters/tests/common/sanitizeTag.spec.js index b6d64dcf9..89014b64b 100644 --- a/packages/bruno-converters/tests/common/sanitizeTag.spec.js +++ b/packages/bruno-converters/tests/common/sanitizeTag.spec.js @@ -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(); + }); }); }); }); 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 index 513a7976a..574e6fc4c 100644 --- 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 @@ -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(); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 515cfbd5c..64170d165 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -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'), diff --git a/packages/bruno-schema/src/collections/itemSchema.spec.js b/packages/bruno-schema/src/collections/itemSchema.spec.js index 87bd048cf..9f12195f0 100644 --- a/packages/bruno-schema/src/collections/itemSchema.spec.js +++ b/packages/bruno-schema/src/collections/itemSchema.spec.js @@ -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 () => {