fix: avoid round trip loss of annotation data (#7730)

* fix: avoid round trip loss of annotation data

* feat: update types for file , multipart and tests for the same

* chore: optional

* chore: fix body:file annotation

* chore: remove log
This commit is contained in:
Sid
2026-04-14 18:47:39 +05:30
committed by lohit-bruno
parent e964bdc7fe
commit 95fccbeb8d
16 changed files with 517 additions and 9 deletions

View File

@@ -1079,11 +1079,12 @@ export const collectionsSlice = createSlice({
item.draft = cloneDeep(item);
}
const existingOtherParams = item.draft.request.params?.filter((p) => p.type !== 'query') || [];
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', type = 'query', enabled = true }) => ({
const newQueryParams = map(params, ({ uid, name = '', value = '', description = '', annotations = null, type = 'query', enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
type,
enabled
}));
@@ -1325,11 +1326,12 @@ export const collectionsSlice = createSlice({
if (!item.draft) {
item.draft = cloneDeep(item);
}
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
item.draft.request.headers = map(action.payload.headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},
@@ -1353,11 +1355,12 @@ export const collectionsSlice = createSlice({
collection.draft.root.request = {};
}
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
collection.draft.root.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},
@@ -1380,11 +1383,12 @@ export const collectionsSlice = createSlice({
if (!folder.draft.request) {
folder.draft.request = {};
}
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', enabled = true }) => ({
folder.draft.request.headers = map(headers, ({ uid, name = '', value = '', description = '', annotations = null, enabled = true }) => ({
uid: uid || uuid(),
name,
value,
description,
annotations,
enabled
}));
},

View File

@@ -181,6 +181,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
};
});
@@ -193,6 +194,7 @@ export const transformCollectionToSaveToExportAsFile = (collection, options = {}
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
};
@@ -745,6 +747,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
});
@@ -757,6 +760,7 @@ export const transformRequestToSaveToFilesystem = (item) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});
@@ -813,6 +817,7 @@ export const transformCollectionRootToSave = (collection) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});
@@ -843,6 +848,7 @@ export const transformFolderRootToSave = (folder) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});

View File

@@ -1,5 +1,5 @@
const { describe, it, expect } = require('@jest/globals');
import { mergeHeaders } from './index';
import { mergeHeaders, transformRequestToSaveToFilesystem } from './index';
describe('mergeHeaders', () => {
it('should include headers from collection, folder and request (with correct precedence)', () => {
@@ -35,3 +35,54 @@ describe('mergeHeaders', () => {
expect(names).toEqual(expect.arrayContaining(['X-Collection', 'X-Folder', 'X-Request']));
});
});
describe('transformRequestToSaveToFilesystem', () => {
it('preserves header and param annotations', () => {
const item = {
uid: 'requestuid123456789012',
type: 'http-request',
name: 'Annotated Request',
seq: 1,
settings: {},
tags: [],
examples: [],
request: {
method: 'GET',
url: 'https://example.com',
params: [
{
uid: 'paramuid1234567890123',
name: 'q',
value: '1',
description: '',
annotations: [{ name: 'param-note', value: 'keep me' }],
type: 'query',
enabled: true
}
],
headers: [
{
uid: 'headeruid123456789012',
name: 'X-Test',
value: '1',
description: '',
annotations: [{ name: 'header-note', value: 'keep me' }],
enabled: true
}
],
auth: { mode: 'none' },
body: { mode: 'none' },
script: { req: '', res: '' },
vars: { req: [], res: [] },
assertions: [],
tests: '',
docs: ''
}
};
const transformed = transformRequestToSaveToFilesystem(item);
expect(transformed.request.params[0].annotations).toEqual([{ name: 'param-note', value: 'keep me' }]);
expect(transformed.request.headers[0].annotations).toEqual([{ name: 'header-note', value: 'keep me' }]);
});
});

View File

@@ -645,6 +645,7 @@ const transformRequestToSaveToFilesystem = (item) => {
name: param.name,
value: param.value,
description: param.description,
annotations: param.annotations,
type: param.type,
enabled: param.enabled
});
@@ -657,6 +658,7 @@ const transformRequestToSaveToFilesystem = (item) => {
name: header.name,
value: header.value,
description: header.description,
annotations: header.annotations,
enabled: header.enabled
});
});

View File

@@ -20,6 +20,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'param1',
value: 'value1',
description: 'Test parameter',
annotations: [{ name: 'note', value: 'param annotation' }],
type: 'text',
enabled: true
}
@@ -30,6 +31,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'Content-Type',
value: 'application/json',
description: 'Request content type',
annotations: [{ name: 'note', value: 'header annotation' }],
enabled: true
}
],
@@ -101,6 +103,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'param1',
value: 'value1',
description: 'Test parameter',
annotations: [{ name: 'note', value: 'param annotation' }],
type: 'text',
enabled: true
});
@@ -112,6 +115,7 @@ describe('transformRequestToSaveToFilesystem', () => {
name: 'Content-Type',
value: 'application/json',
description: 'Request content type',
annotations: [{ name: 'note', value: 'header annotation' }],
enabled: true
});
});

View File

@@ -585,10 +585,11 @@ ${indentString(body.sparql)}
const selected = item.selected ? '' : '~';
const contentType
= item.contentType && item.contentType !== '' ? ' @contentType(' + item.contentType + ')' : '';
const annotPrefix = serializeAnnotations(item.annotations);
const filePath = item.filePath || '';
const value = `@file(${filePath})`;
const itemName = 'file';
return `${selected}${itemName}: ${value}${contentType}`;
return `${annotPrefix}${selected}${itemName}: ${value}${contentType}`;
})
.join('\n')
)}`;

View File

@@ -320,6 +320,35 @@ headers {
expect(parsed.headers[0].annotations).toEqual([{ name: 'description', value: '{{baseUrl}}/path' }]);
});
it('serializeAnnotations — annotation on params:path', () => {
const json = {
params: [{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }]
};
const bru = jsonToBru(json);
expect(bru).toContain('params:path {');
expect(bru).toContain('@description(\'user id\')\n userId: 123');
});
it('serializeAnnotations — annotation on metadata', () => {
const json = {
metadata: [{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }]
};
const bru = jsonToBru(json);
expect(bru).toContain('metadata {');
expect(bru).toContain('@description(\'trace id\')\n trace-id: abc123');
});
it('serializeAnnotations — annotation on body:form-urlencoded', () => {
const json = {
body: {
formUrlEncoded: [{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('body:form-urlencoded {');
expect(bru).toContain('@description(\'username field\')\n username: alice');
});
it('annotation on params:query block', () => {
const input = `
params:query {
@@ -333,6 +362,45 @@ params:query {
]);
});
it('annotation on params:path block', () => {
const input = `
params:path {
@description('user id')
userId: 123
}
`;
const output = parser(input);
expect(output.params).toEqual([
{ name: 'userId', value: '123', enabled: true, type: 'path', annotations: [{ name: 'description', value: 'user id' }] }
]);
});
it('annotation on metadata block', () => {
const input = `
metadata {
@description('trace id')
trace-id: abc123
}
`;
const output = parser(input);
expect(output.metadata).toEqual([
{ name: 'trace-id', value: 'abc123', enabled: true, annotations: [{ name: 'description', value: 'trace id' }] }
]);
});
it('annotation on body:form-urlencoded block', () => {
const input = `
body:form-urlencoded {
@description('username field')
username: alice
}
`;
const output = parser(input);
expect(output.body.formUrlEncoded).toEqual([
{ name: 'username', value: 'alice', enabled: true, annotations: [{ name: 'description', value: 'username field' }] }
]);
});
it('annotation on vars:pre-request block', () => {
const input = `
vars:pre-request {
@@ -352,6 +420,225 @@ vars:pre-request {
]);
});
it('annotation on vars:post-response block', () => {
const input = `
vars:post-response {
@description('auth token')
token: abc123
}
`;
const output = parser(input);
expect(output.vars.res).toEqual([
{
name: 'token',
value: 'abc123',
enabled: true,
local: false,
annotations: [{ name: 'description', value: 'auth token' }]
}
]);
});
it('annotation on local vars:pre-request pair', () => {
const input = `
vars:pre-request {
@description('local base url')
@BASE_URL: http://localhost
}
`;
const output = parser(input);
expect(output.vars.req).toEqual([
{
name: 'BASE_URL',
value: 'http://localhost',
enabled: true,
local: true,
annotations: [{ name: 'description', value: 'local base url' }]
}
]);
});
it('annotation on local vars:post-response pair', () => {
const input = `
vars:post-response {
@description('local token')
@token: abc123
}
`;
const output = parser(input);
expect(output.vars.res).toEqual([
{
name: 'token',
value: 'abc123',
enabled: true,
local: true,
annotations: [{ name: 'description', value: 'local token' }]
}
]);
});
it('annotation on body:multipart-form text field', () => {
const input = `
body:multipart-form {
@description('plain field')
field: value @contentType(text/plain)
}
`;
const output = parser(input);
expect(output.body.multipartForm).toEqual([
{
name: 'field',
value: 'value',
enabled: true,
type: 'text',
contentType: 'text/plain',
annotations: [{ name: 'description', value: 'plain field' }]
}
]);
});
it('annotation on body:multipart-form file field', () => {
const input = `
body:multipart-form {
@description('upload image')
upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)
}
`;
const output = parser(input);
expect(output.body.multipartForm).toEqual([
{
name: 'upload',
value: ['/tmp/a.png', '/tmp/b.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]);
});
it('annotation on body:file', () => {
const input = `
body:file {
@description('upload doc')
file: @file(/tmp/readme.pdf) @contentType(application/pdf)
}
`;
const output = parser(input);
expect(output.body.file).toEqual([
{
filePath: '/tmp/readme.pdf',
selected: true,
contentType: 'application/pdf',
annotations: [{ name: 'description', value: 'upload doc' }]
}
]);
});
it('serializeAnnotations — multipart text field with contentType', () => {
const json = {
body: {
multipartForm: [
{
name: 'field',
value: 'value',
enabled: true,
type: 'text',
contentType: 'text/plain',
annotations: [{ name: 'description', value: 'plain field' }]
}
]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('@description(\'plain field\')\n field: value @contentType(text/plain)');
});
it('serializeAnnotations — multipart file field with contentType', () => {
const json = {
body: {
multipartForm: [
{
name: 'upload',
value: ['/tmp/a.png', '/tmp/b.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('@description(\'upload image\')\n upload: @file(/tmp/a.png|/tmp/b.png) @contentType(image/png)');
});
it('serializeAnnotations — annotation on vars:post-response', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
});
it('serializeAnnotations — annotation on local vars:pre-request', () => {
const json = {
vars: {
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:pre-request {');
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
});
it('serializeAnnotations — annotation on disabled local vars:post-response', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
});
it('serializeAnnotations — body:file with annotations', () => {
const json = {
body: {
file: [{ filePath: '/tmp/readme.pdf', selected: true, contentType: 'application/pdf', annotations: [{ name: 'description', value: 'upload doc' }] }]
}
};
const bru = jsonToBru(json);
expect(bru).toContain('body:file {');
expect(bru).toContain('@description(\'upload doc\')\n file: @file(/tmp/readme.pdf) @contentType(application/pdf)');
const parsed = parser(bru);
expect(parsed.body.file).toEqual(json.body.file);
});
it('roundtrip — multipart annotation survives json→bru→json', () => {
const json = {
body: {
multipartForm: [
{
name: 'upload',
value: ['/tmp/a.png'],
enabled: true,
type: 'file',
contentType: 'image/png',
annotations: [{ name: 'description', value: 'upload image' }]
}
]
}
};
const bru = jsonToBru(json);
const parsed = parser(bru);
expect(parsed.body.multipartForm).toEqual(json.body.multipartForm);
});
it('roundtrip: bru → json → bru → json equal', () => {
const input = `get {
url: https://example.com
@@ -792,6 +1079,39 @@ describe('collection pair annotations', () => {
expect(bru).toContain('@description(\'base url\')\n BASE_URL: http://localhost');
});
it('serializeAnnotations in jsonToCollectionBru — vars:post-response with annotation', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: true, local: false, annotations: [{ name: 'description', value: 'auth token' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'auth token\')\n token: abc123');
});
it('serializeAnnotations in jsonToCollectionBru — local vars:pre-request with annotation', () => {
const json = {
vars: {
req: [{ name: 'BASE_URL', value: 'http://localhost', enabled: true, local: true, annotations: [{ name: 'description', value: 'local base url' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:pre-request {');
expect(bru).toContain('@description(\'local base url\')\n @BASE_URL: http://localhost');
});
it('serializeAnnotations in jsonToCollectionBru — disabled local vars:post-response with annotation', () => {
const json = {
vars: {
res: [{ name: 'token', value: 'abc123', enabled: false, local: true, annotations: [{ name: 'description', value: 'local token' }] }]
}
};
const bru = jsonToCollectionBru(json);
expect(bru).toContain('vars:post-response {');
expect(bru).toContain('@description(\'local token\')\n ~@token: abc123');
});
it('parseAndSerialise - bru sourced roundtrip check - collection headers', () => {
const input = `headers {
@description('content type')

View File

@@ -1,4 +1,4 @@
import type { UID } from '../common';
import type { UID, Annotation } from '../common';
export interface EnvironmentVariable {
uid: UID;
@@ -7,6 +7,7 @@ export interface EnvironmentVariable {
type: 'text';
enabled?: boolean;
secret?: boolean;
annotations?: Annotation[] | null;
}
export interface Environment {

View File

@@ -0,0 +1,7 @@
/**
* Annotation applied to pairs (headers, vars, params, etc.)
*/
export interface Annotation {
name: string;
value?: string | null;
}

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
export interface FileEntry {
@@ -5,6 +6,7 @@ export interface FileEntry {
filePath?: string | null;
contentType?: string | null;
selected: boolean;
annotations?: Annotation[];
}
export type FileList = FileEntry[];

View File

@@ -1,6 +1,7 @@
export type { UID } from './uid';
export type { KeyValue } from './key-value';
export type { Variable, Variables } from './variables';
export type { Annotation } from './annotation';
export type { MultipartFormEntry, MultipartForm } from './multipart-form';
export type { FileEntry, FileList } from './file';
export type { GraphqlBody } from './graphql';

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
/**
@@ -9,4 +10,5 @@ export interface KeyValue {
value?: string | null;
description?: string | null;
enabled?: boolean;
annotations?: Annotation[] | null;
}

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
export interface MultipartFormEntry {
@@ -8,6 +9,7 @@ export interface MultipartFormEntry {
description?: string | null;
contentType?: string | null;
enabled?: boolean;
annotations?: Annotation[];
}
export type MultipartForm = MultipartFormEntry[];

View File

@@ -1,3 +1,4 @@
import { Annotation } from './annotation';
import type { UID } from './uid';
/**
@@ -10,6 +11,7 @@ export interface Variable {
description?: string | null;
enabled?: boolean;
local?: boolean;
annotations?: Annotation[] | null;
}
export type Variables = Variable[] | null;

View File

@@ -0,0 +1,53 @@
const { itemSchema, environmentSchema, collectionSchema } = require('./index');
describe('annotation acceptance', () => {
test('itemSchema accepts annotations on headers and params', async () => {
const item = {
uid: 'aaaaaaaaaaaaaaaaaaaaa',
type: 'http-request',
name: 'Req',
request: {
url: 'https://example.com',
method: 'GET',
headers: [
{ uid: 'bbbbbbbbbbbbbbbbbbbbb', name: 'X-Test', value: '1', annotations: [{ name: 'note', value: 'header note' }] }
],
params: [
{ uid: 'ccccccccccccccccccccc', name: 'q', value: '1', type: 'query', annotations: [{ name: 'hint' }] }
],
},
};
await expect(itemSchema.validate(item)).resolves.toBeTruthy();
});
test('environmentSchema accepts annotations on variables', async () => {
const env = {
uid: 'ddddddddddddddddddddd',
name: 'Env',
variables: [
{ uid: 'eeeeeeeeeeeeeeeeeeeee', name: 'API_KEY', value: 'abc', annotations: [{ name: 'secret', value: null }], type: 'text', enabled: true, secret: false }
]
};
await expect(environmentSchema.validate(env)).resolves.toBeTruthy();
});
test('collectionSchema accepts annotations in item vars and items', async () => {
const coll = {
version: '1',
uid: 'fffffffffffffffffffff',
name: 'Coll',
items: [
{
uid: 'ggggggggggggggggggggg',
type: 'http-request',
name: 'Req2',
request: { url: '/path', method: 'POST', headers: [], params: [], vars: { req: [{ uid: 'hhhhhhhhhhhhhhhhhhhhh', name: 'base', value: 'https://example.com', annotations: [{ name: 'base-note' }] }] } }
}
]
};
await expect(collectionSchema.validate(coll)).resolves.toBeTruthy();
});
});

View File

@@ -1,11 +1,22 @@
const Yup = require('yup');
const { uidSchema } = require('../common');
const annotationSchema = Yup.object({
name: Yup.string().min(1).required('annotation name is required'),
value: Yup.string().nullable()
}).noUnknown(true)
.strict();
const environmentVariablesSchema = Yup.object({
uid: uidSchema,
name: Yup.string().nullable(),
// Allow mixed types (string, number, boolean, object) to support setting non-string values via scripts.
value: Yup.mixed().nullable(),
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
type: Yup.string().oneOf(['text']).required('type is required'),
enabled: Yup.boolean().defined(),
secret: Yup.boolean()
@@ -29,6 +40,11 @@ const keyValueSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
enabled: Yup.boolean()
})
.noUnknown(true)
@@ -79,6 +95,12 @@ const varsSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
// Optional annotations on variables
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
enabled: Yup.boolean(),
// todo
@@ -109,6 +131,17 @@ const multipartFormSchema = Yup.object({
then: Yup.array().of(Yup.string().nullable()).nullable(),
otherwise: Yup.string().nullable()
}),
// Optional annotations on multipart entries
annotations: Yup.array()
.of(
Yup.object({
name: Yup.string().min(1).required('annotation name is required'),
value: Yup.string().nullable()
})
.noUnknown(true)
.strict()
)
.nullable(),
description: Yup.string().nullable(),
contentType: Yup.string().nullable(),
enabled: Yup.boolean()
@@ -126,6 +159,16 @@ const fileSchema = Yup.object({
.noUnknown(true)
.strict();
// Add annotations to file entries (when parsed from body:file blocks they can have @contentType only currently,
// but adding annotations ensures roundtrip validation doesn't fail if annotations are present in future)
const fileSchemaWithAnnotations = fileSchema.shape({
annotations: Yup.array()
.of(
annotationSchema
)
.nullable()
});
const requestBodySchema = Yup.object({
mode: Yup.string()
.oneOf(['none', 'json', 'text', 'xml', 'formUrlEncoded', 'multipartForm', 'graphql', 'sparql', 'file'])
@@ -137,7 +180,7 @@ const requestBodySchema = Yup.object({
formUrlEncoded: Yup.array().of(keyValueSchema).nullable(),
multipartForm: Yup.array().of(multipartFormSchema).nullable(),
graphql: graphqlBodySchema.nullable(),
file: Yup.array().of(fileSchema).nullable()
file: Yup.array().of(fileSchemaWithAnnotations).nullable()
})
.noUnknown(true)
.strict();
@@ -378,6 +421,12 @@ const requestParamsSchema = Yup.object({
name: Yup.string().nullable(),
value: Yup.string().nullable(),
description: Yup.string().nullable(),
// Optional annotations on params
annotations: Yup.array()
.of(
annotationSchema
)
.nullable(),
type: Yup.string().oneOf(['query', 'path']).required('type is required'),
enabled: Yup.boolean()
})
@@ -649,5 +698,6 @@ module.exports = {
itemSchema,
environmentSchema,
environmentsSchema,
collectionSchema
collectionSchema,
annotationSchema
};