mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-11 09:51:30 +00:00
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:
@@ -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, '_');
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user