diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/environment-events.spec.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/environment-events.spec.js new file mode 100644 index 000000000..a24a718fb --- /dev/null +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/environment-events.spec.js @@ -0,0 +1,102 @@ +import reducer, { collectionAddEnvFileEvent } from 'providers/ReduxStore/slices/collections'; + +const COLLECTION_UID = 'col-1'; +const ENV_UID = 'env-1'; + +const externalSecrets = { + type: 'my-vault', + variables: [ + { name: 'secret', value: 'secret/data/secret' }, + { name: 'password', value: 'secret/data/password' } + ] +}; + +const makeEnvironment = (overrides = {}) => ({ + uid: ENV_UID, + name: 'test_env', + pathname: '/coll/environments/test_env.bru', + variables: [ + { uid: 'var-1', name: 'env_str', value: 'env_string', type: 'text', enabled: true, secret: false }, + { uid: 'var-2', name: 'env_num', value: '300', type: 'text', datatype: 'number', enabled: true, secret: false }, + { uid: 'var-3', name: 'env_bool', value: 'true', type: 'text', datatype: 'boolean', enabled: true, secret: false }, + { uid: 'var-4', name: 'env_obj', value: '{"scope":"env"}', type: 'text', datatype: 'object', enabled: true, secret: false } + ], + color: null, + ...overrides +}); + +const makeInitialState = (environments = []) => ({ + collections: [ + { + uid: COLLECTION_UID, + pathname: '/coll', + items: [], + environments + } + ], + collectionSortOrder: 'default', + activeWorkspaceUid: null +}); + +describe('collectionAddEnvFileEvent', () => { + it('keeps externalSecrets when a new environment is added', () => { + const state = makeInitialState(); + + const nextState = reducer( + state, + collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID }) + ); + + expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets); + }); + + it('keeps externalSecrets when an existing environment changes', () => { + const state = makeInitialState([makeEnvironment()]); + + const nextState = reducer( + state, + collectionAddEnvFileEvent({ environment: makeEnvironment({ externalSecrets }), collectionUid: COLLECTION_UID }) + ); + + expect(nextState.collections[0].environments[0].externalSecrets).toEqual(externalSecrets); + }); + + it('keeps variable datatype when a new environment is added', () => { + const state = makeInitialState(); + + const nextState = reducer( + state, + collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID }) + ); + + const variables = nextState.collections[0].environments[0].variables; + expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' }); + expect(variables.find((v) => v.name === 'env_bool')).toMatchObject({ value: 'true', datatype: 'boolean' }); + expect(variables.find((v) => v.name === 'env_obj')).toMatchObject({ value: '{"scope":"env"}', datatype: 'object' }); + expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype'); + }); + + it('keeps variable datatype when an existing environment changes', () => { + const state = makeInitialState([makeEnvironment({ variables: [] })]); + + const nextState = reducer( + state, + collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID }) + ); + + const variables = nextState.collections[0].environments[0].variables; + expect(variables.find((v) => v.name === 'env_num')).toMatchObject({ value: '300', datatype: 'number' }); + expect(variables.find((v) => v.name === 'env_str')).not.toHaveProperty('datatype'); + }); + + it('clears externalSecrets when the block is removed from the file', () => { + const state = makeInitialState([makeEnvironment({ externalSecrets })]); + + const nextState = reducer( + state, + collectionAddEnvFileEvent({ environment: makeEnvironment(), collectionUid: COLLECTION_UID }) + ); + + expect(nextState.collections[0].environments[0].externalSecrets).toBeUndefined(); + }); +}); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js index 5586e491e..c2f740f6a 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/collections/index.js @@ -2067,11 +2067,13 @@ export const collectionsSlice = createSlice({ item.draft = cloneDeep(item); } item.draft.request.vars = item.draft.request.vars || {}; - const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({ uid: uid || uuid(), name, value, enabled, + ...(datatype ? { datatype } : {}), + ...(annotations?.length ? { annotations } : {}), ...(type === 'response' ? { local } : {}) })); if (type === 'request') { @@ -2421,11 +2423,13 @@ export const collectionsSlice = createSlice({ if (!folder.draft) { folder.draft = cloneDeep(folder.root); } - const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({ uid: uid || uuid(), name, value, enabled, + ...(datatype ? { datatype } : {}), + ...(annotations?.length ? { annotations } : {}), ...(type === 'response' ? { local } : {}) })); if (type === 'request') { @@ -2659,11 +2663,13 @@ export const collectionsSlice = createSlice({ root: cloneDeep(collection.root) }; } - const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false }) => ({ + const mappedVars = map(vars, ({ uid, name = '', value = '', enabled = true, local = false, datatype, annotations }) => ({ uid: uid || uuid(), name, value, enabled, + ...(datatype ? { datatype } : {}), + ...(annotations?.length ? { annotations } : {}), ...(type === 'response' ? { local } : {}) })); if (type === 'request') { @@ -2912,6 +2918,7 @@ export const collectionsSlice = createSlice({ existingEnv.pathname = environment.pathname; existingEnv.variables = environment.variables; existingEnv.color = environment.color; + existingEnv.externalSecrets = environment.externalSecrets; /* Apply temporary (ephemeral) values only to variables that actually exist in the file. This prevents deleted temporaries from “popping back” after a save. If a variable is present in the file, we temporarily override the UI value while also remembering the on-disk value in persistedValue for future saves. */ diff --git a/packages/bruno-filestore/src/formats/yml/common/variables.ts b/packages/bruno-filestore/src/formats/yml/common/variables.ts index 6295a3ac3..ab7fdc8af 100644 --- a/packages/bruno-filestore/src/formats/yml/common/variables.ts +++ b/packages/bruno-filestore/src/formats/yml/common/variables.ts @@ -1,8 +1,18 @@ -import { Variable } from '@opencollection/types/common/variables'; +import { Variable, VariableTypedValue } from '@opencollection/types/common/variables'; import { FolderRequest as BrunoFolderRequest } from '@usebruno/schema-types/collection/folder'; import { Variable as BrunoVariable, Variables as BrunoVariables } from '@usebruno/schema-types/common/variables'; import { uuid, ensureString } from '../../../utils'; +export const isTypedValue = (value: unknown): value is VariableTypedValue => { + return ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + && 'type' in value + && 'data' in value + ); +}; + /** * Convert Bruno pre-request variables to OpenCollection variables format. * Note: Post-response variables are now converted to actions (see actions.ts). @@ -21,7 +31,10 @@ export const toOpenCollectionVariables = (variables: BrunoFolderRequest['vars'] const ocVariables: Variable[] = reqVarsArray.map((v: BrunoVariable): Variable => { const variable: Variable = { name: v.name || '', - value: v.value || '' + value: + v.datatype && v.datatype !== 'string' + ? { type: v.datatype, data: ensureString(v.value) } + : v.value || '' }; if (v?.description?.trim().length) { @@ -52,11 +65,20 @@ export const toBrunoVariables = (variables: Variable[] | null | undefined): { re const variable: BrunoVariable = { uid: uuid(), name: ensureString(v.name), - value: ensureString(v.value), + value: '', enabled: v.disabled !== true, local: false }; + if (isTypedValue(v.value)) { + variable.value = ensureString(v.value.data); + if (v.value.type !== 'string' && v.value.type !== 'null') { + variable.datatype = v.value.type; + } + } else { + variable.value = ensureString(v.value); + } + if (v.description) { variable.description = typeof v.description === 'string' ? v.description : (v.description as any)?.content || ''; } diff --git a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts index 92b4016b8..6cfe2e90d 100644 --- a/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/parseEnvironment.ts @@ -3,6 +3,7 @@ import type { Environment } from '@opencollection/types/config/environments'; import type { Variable, SecretVariable } from '@opencollection/types/common/variables'; import { parseYml } from './utils'; import { uuid, ensureString } from '../../utils'; +import { isTypedValue } from './common/variables'; const isSecretVariable = (v: Variable | SecretVariable): v is SecretVariable => { return 'secret' in v && v.secret === true; @@ -15,7 +16,7 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] | return variables.map((v): BrunoEnvironmentVariable => { if (isSecretVariable(v)) { - return { + const variable: BrunoEnvironmentVariable = { uid: uuid(), name: ensureString(v.name), value: '', @@ -23,19 +24,55 @@ const toBrunoEnvironmentVariables = (variables: (Variable | SecretVariable)[] | enabled: v.disabled !== true, secret: true }; + + if (v.type && v.type !== 'string' && v.type !== 'null') { + variable.datatype = v.type; + } + + return variable; } const variable: BrunoEnvironmentVariable = { uid: uuid(), name: ensureString(v.name), - value: ensureString(v.value), + value: '', type: 'text', enabled: v.disabled !== true, secret: false }; + + if (isTypedValue(v.value)) { + variable.value = ensureString(v.value.data); + if (v.value.type !== 'string' && v.value.type !== 'null') { + variable.datatype = v.value.type; + } + } else { + variable.value = ensureString(v.value); + } + return variable; }); }; +const toBrunoExternalSecrets = (externalSecrets: any): BrunoEnvironment['externalSecrets'] | undefined => { + if (!externalSecrets || typeof externalSecrets !== 'object') { + return undefined; + } + + const variables = Array.isArray(externalSecrets.variables) + ? externalSecrets.variables.map((variable: any) => { + const result: Record = { name: ensureString(variable?.name) }; + Object.keys(variable || {}).forEach((key) => { + if (key !== 'name') { + result[key] = ensureString(variable[key]); + } + }); + return result; + }) + : []; + + return { type: ensureString(externalSecrets.type), variables } as BrunoEnvironment['externalSecrets']; +}; + const parseEnvironment = (ymlString: string): BrunoEnvironment => { try { const ocEnvironment: Environment = parseYml(ymlString); @@ -47,6 +84,11 @@ const parseEnvironment = (ymlString: string): BrunoEnvironment => { color: ocEnvironment.color || null }; + const externalSecrets = toBrunoExternalSecrets((ocEnvironment as any).externalSecrets); + if (externalSecrets) { + brunoEnvironment.externalSecrets = externalSecrets; + } + return brunoEnvironment; } catch (error) { console.error('Error parsing environment:', error); diff --git a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts index 2f57f4aad..91acdb4fa 100644 --- a/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts +++ b/packages/bruno-filestore/src/formats/yml/stringifyEnvironment.ts @@ -2,6 +2,7 @@ import type { Environment as BrunoEnvironment, EnvironmentVariable as BrunoEnvir import type { Environment } from '@opencollection/types/config/environments'; import type { Variable, SecretVariable } from '@opencollection/types/common/variables'; import { stringifyYml } from './utils'; +import { ensureString } from '../../utils'; const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariable[]): (Variable | SecretVariable)[] | undefined => { if (!variables?.length) { @@ -20,6 +21,9 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl secret: true, name: v.name || '' }; + if (v.datatype && v.datatype !== 'string') { + secretVar.type = v.datatype; + } if (v.enabled === false) { secretVar.disabled = true; } @@ -28,7 +32,10 @@ const toOpenCollectionEnvironmentVariables = (variables: BrunoEnvironmentVariabl const variable: Variable = { name: v.name || '', - value: v.value as string + value: + v.datatype && v.datatype !== 'string' + ? { type: v.datatype, data: ensureString(v.value) } + : ensureString(v.value) }; if (v.enabled === false) { @@ -58,6 +65,13 @@ const stringifyEnvironment = (environment: BrunoEnvironment): string => { } } + if (environment.externalSecrets) { + (ocEnvironment as any).externalSecrets = { + type: environment.externalSecrets.type, + variables: (environment.externalSecrets.variables || []).map((variable) => ({ ...variable })) + }; + } + return stringifyYml(ocEnvironment); } catch (error) { console.error('Error stringifying environment:', error); diff --git a/packages/bruno-filestore/src/formats/yml/tests/parseEnvironment.spec.js b/packages/bruno-filestore/src/formats/yml/tests/parseEnvironment.spec.js new file mode 100644 index 000000000..52ce4464f --- /dev/null +++ b/packages/bruno-filestore/src/formats/yml/tests/parseEnvironment.spec.js @@ -0,0 +1,161 @@ +import parseEnvironment from '../parseEnvironment'; +import stringifyEnvironment from '../stringifyEnvironment'; + +const ENV_YML = `name: test_env +variables: + - name: env_str + value: env_string + - name: env_num + value: + type: number + data: "300" + - name: env_bool + value: + type: boolean + data: "true" + - name: env_obj + value: + type: object + data: |- + { + "scope": "env" + } + - name: falsy_num + value: + type: number + data: "0" + - name: falsy_bool + value: + type: boolean + data: "false" + - secret: true + name: env_secret_str + - secret: true + name: env_secret_num + type: number + - secret: true + name: env_secret_bool + type: boolean + - secret: true + name: env_secret_obj + type: object + - name: env_array_obj + value: + type: object + data: "[1,2,3,4]" +`; + +const byName = (env) => Object.fromEntries(env.variables.map((v) => [v.name, v])); + +describe('yml parseEnvironment - typed values', () => { + it('keeps the value as a string and preserves the type via datatype', () => { + const variables = byName(parseEnvironment(ENV_YML)); + + expect(variables.env_num).toMatchObject({ value: '300', type: 'text', datatype: 'number' }); + expect(typeof variables.env_num.value).toBe('string'); + + expect(variables.env_bool).toMatchObject({ value: 'true', datatype: 'boolean' }); + expect(variables.falsy_num).toMatchObject({ value: '0', datatype: 'number' }); + expect(variables.falsy_bool).toMatchObject({ value: 'false', datatype: 'boolean' }); + + expect(variables.env_obj.datatype).toBe('object'); + expect(typeof variables.env_obj.value).toBe('string'); + expect(variables.env_obj.value).toContain('"scope": "env"'); + + expect(variables.env_array_obj).toMatchObject({ value: '[1,2,3,4]', datatype: 'object' }); + }); + + it('does not set datatype for plain string values', () => { + const variables = byName(parseEnvironment(ENV_YML)); + + expect(variables.env_str).toMatchObject({ value: 'env_string', type: 'text', secret: false }); + expect(variables.env_str).not.toHaveProperty('datatype'); + }); + + it('parses secret variables with no value or datatype', () => { + const variables = byName(parseEnvironment(ENV_YML)); + + expect(variables.env_secret_str).toMatchObject({ name: 'env_secret_str', value: '', secret: true }); + expect(variables.env_secret_str).not.toHaveProperty('datatype'); + }); + + it('parses secret variables with a type, keeping the value empty and the type in datatype', () => { + const variables = byName(parseEnvironment(ENV_YML)); + + expect(variables.env_secret_num).toMatchObject({ value: '', secret: true, datatype: 'number' }); + expect(variables.env_secret_bool).toMatchObject({ value: '', secret: true, datatype: 'boolean' }); + expect(variables.env_secret_obj).toMatchObject({ value: '', secret: true, datatype: 'object' }); + }); + + it('serializes secret variable datatype back to a type field, omitting it for plain secrets', () => { + const yml = stringifyEnvironment(parseEnvironment(ENV_YML)); + + expect(yml).toContain('- secret: true\n name: env_secret_num\n type: number'); + expect(yml).toContain('- secret: true\n name: env_secret_bool\n type: boolean'); + expect(yml).toContain('- secret: true\n name: env_secret_obj\n type: object'); + expect(yml).toContain('- secret: true\n name: env_secret_str'); + expect(yml).not.toContain('name: env_secret_str\n type:'); + }); + + it('serializes datatype back to an OpenCollection { type, data } value', () => { + const yml = stringifyEnvironment(parseEnvironment(ENV_YML)); + + expect(yml).toContain('type: number'); + expect(yml).toContain('data: "300"'); + expect(yml).toContain('type: boolean'); + expect(yml).toContain('type: object'); + // plain strings stay plain, never wrapped as a string datatype + expect(yml).toContain('value: env_string'); + expect(yml).not.toContain('type: string'); + }); + + it('round-trips value and datatype through parse -> stringify -> parse', () => { + const env = parseEnvironment(ENV_YML); + const reparsed = parseEnvironment(stringifyEnvironment(env)); + + const withoutUid = (e) => e.variables.map(({ uid, ...rest }) => rest); + expect(withoutUid(reparsed)).toEqual(withoutUid(env)); + }); +}); + +const EXTERNAL_SECRETS_YML = `name: test_env +variables: + - name: env_str + value: env_string +externalSecrets: + type: my-vault + variables: + - name: by_path + path: secret/data/secret + - name: by_secret_name + secretName: secret + - name: by_vault_name + vaultName: secret +`; + +describe('yml parseEnvironment - external secrets', () => { + it('parses externalSecrets, preserving the type and arbitrary variable keys', () => { + const env = parseEnvironment(EXTERNAL_SECRETS_YML); + + expect(env.externalSecrets).toEqual({ + type: 'my-vault', + variables: [ + { name: 'by_path', path: 'secret/data/secret' }, + { name: 'by_secret_name', secretName: 'secret' }, + { name: 'by_vault_name', vaultName: 'secret' } + ] + }); + }); + + it('does not set externalSecrets when the yml has none', () => { + const env = parseEnvironment('name: test_env\nvariables: []\n'); + expect(env.externalSecrets).toBeUndefined(); + }); + + it('round-trips externalSecrets through parse -> stringify -> parse', () => { + const env = parseEnvironment(EXTERNAL_SECRETS_YML); + const reparsed = parseEnvironment(stringifyEnvironment(env)); + + expect(reparsed.externalSecrets).toEqual(env.externalSecrets); + }); +}); diff --git a/packages/bruno-filestore/src/formats/yml/tests/variables.spec.js b/packages/bruno-filestore/src/formats/yml/tests/variables.spec.js new file mode 100644 index 000000000..413d6adfb --- /dev/null +++ b/packages/bruno-filestore/src/formats/yml/tests/variables.spec.js @@ -0,0 +1,34 @@ +import { toBrunoVariables, toOpenCollectionVariables } from '../common/variables'; + +describe('yml variables - typed values (collection / folder / request vars)', () => { + it('reads a typed value keeping the value as a string and the type in datatype', () => { + const ocVariables = [ + { name: 'var_str', value: 'plain' }, + { name: 'var_num', value: { type: 'number', data: '300' } }, + { name: 'var_bool', value: { type: 'boolean', data: 'false' } }, + { name: 'var_obj', value: { type: 'object', data: '{"scope":"folder"}' } } + ]; + + const { req } = toBrunoVariables(ocVariables); + + expect(req.find((v) => v.name === 'var_str')).toMatchObject({ value: 'plain' }); + expect(req.find((v) => v.name === 'var_str')).not.toHaveProperty('datatype'); + expect(req.find((v) => v.name === 'var_num')).toMatchObject({ value: '300', datatype: 'number' }); + expect(req.find((v) => v.name === 'var_bool')).toMatchObject({ value: 'false', datatype: 'boolean' }); + expect(req.find((v) => v.name === 'var_obj')).toMatchObject({ value: '{"scope":"folder"}', datatype: 'object' }); + }); + + it('writes datatype back as a { type, data } value, leaving plain strings untouched', () => { + const brunoVariables = [ + { uid: 'u1', name: 'var_str', value: 'plain', enabled: true, local: false }, + { uid: 'u2', name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false } + ]; + + const ocVariables = toOpenCollectionVariables(brunoVariables); + + expect(ocVariables).toEqual([ + { name: 'var_str', value: 'plain' }, + { name: 'var_num', value: { type: 'number', data: '300' } } + ]); + }); +}); diff --git a/packages/bruno-lang/v2/src/envToJson.js b/packages/bruno-lang/v2/src/envToJson.js index 70f036285..0b248e98d 100644 --- a/packages/bruno-lang/v2/src/envToJson.js +++ b/packages/bruno-lang/v2/src/envToJson.js @@ -16,7 +16,7 @@ const ANNOTATIONS_KEY = Symbol('annotations'); // } const indentLevel = 4; const grammar = ohm.grammar(`Bru { - BruEnvFile = (vars | secretvars | color)* + BruEnvFile = (vars | secretvars | externalsecrets | color)* nl = "\\r"? "\\n" st = " " | "\\t" @@ -59,10 +59,13 @@ const grammar = ohm.grammar(`Bru { // Array Blocks array = st* "[" stnl* valuelist stnl* "]" valuelist = stnl* arrayvalue stnl* ("," stnl* arrayvalue)* - arrayvalue = arrayvaluechar* + arrayvalue = pairannotations st* arrayvaluechar* arrayvaluechar = ~(nl | st | "[" | "]" | ",") any secretvars = "vars:secret" array + externalsecrets = "vars:externalsecrets:" externalsecretsname dictionary + externalsecretsname = externalsecretsnamechar+ + externalsecretsnamechar = ~(st | nl | "{") any vars = "vars" dictionary color = "color:" any* }`); @@ -91,25 +94,25 @@ const mapPairListToKeyValPairs = (pairList = []) => { }; const mapArrayListToKeyValPairs = (arrayList = []) => { - arrayList = arrayList.filter((v) => v && v.length); + arrayList = arrayList.filter((item) => item && item.name && item.name.length); if (!arrayList.length) { return []; } - return _.map(arrayList, (value) => { - let name = value; + return _.map(arrayList, (item) => { + let name = item.name; let enabled = true; if (name && name.length && name.charAt(0) === '~') { name = name.slice(1); enabled = false; } - return { - name, - value: '', - enabled - }; + const result = { name, value: '', enabled }; + if (item.annotations && item.annotations.length) { + result.annotations = item.annotations; + } + return result; }); }; @@ -138,8 +141,13 @@ const sem = grammar.createSemantics().addAttribute('ast', { array(_1, _2, _3, valuelist, _4, _5) { return valuelist.ast; }, - arrayvalue(chars) { - return chars.sourceString ? chars.sourceString.trim() : ''; + arrayvalue(annotations, _st, chars) { + const result = { name: chars.sourceString ? chars.sourceString.trim() : '' }; + const annotationList = annotations.ast; + if (annotationList && annotationList.length > 0) { + result.annotations = annotationList; + } + return result; }, valuelist(_1, value, _2, _3, _4, rest) { return [value.ast, ...rest.ast]; @@ -261,6 +269,21 @@ const sem = grammar.createSemantics().addAttribute('ast', { variables: vars }; }, + externalsecrets(_1, name, dictionary) { + const variables = mapPairListToKeyValPairs(dictionary.ast).map((pair) => ({ + name: pair.name, + value: pair.value + })); + return { + externalSecrets: { + type: name.ast, + variables + } + }; + }, + externalsecretsname(chars) { + return chars.sourceString; + }, color: (_1, anystring) => { return { color: anystring.sourceString.trim() diff --git a/packages/bruno-lang/v2/src/jsonToEnv.js b/packages/bruno-lang/v2/src/jsonToEnv.js index 64a8d2cd5..d4429c5e3 100644 --- a/packages/bruno-lang/v2/src/jsonToEnv.js +++ b/packages/bruno-lang/v2/src/jsonToEnv.js @@ -3,6 +3,7 @@ const { getValueString, indentString, serializeAnnotations } = require('./utils' const envToJson = (json) => { const variables = _.get(json, 'variables', []); + const externalSecrets = _.get(json, 'externalSecrets', null); const color = _.get(json, 'color', null); const vars = variables @@ -17,9 +18,9 @@ const envToJson = (json) => { const secretVars = variables .filter((variable) => variable.secret) .map((variable) => { - const { name, enabled } = variable; + const { name, enabled, annotations } = variable; const prefix = enabled ? '' : '~'; - return indentString(`${prefix}${name}`); + return indentString(`${serializeAnnotations(annotations)}${prefix}${name}`); }); let output = ''; @@ -43,6 +44,18 @@ ${secretVars.join(',\n')} ] `; } + + if (externalSecrets && externalSecrets.type) { + const serializedVariables = (externalSecrets.variables || []).map(({ name, value }) => + indentString(`${name}: ${getValueString(value)}`) + ); + + output += `vars:externalsecrets:${externalSecrets.type} { +${serializedVariables.join('\n')} +} +`; + } + if (color) { output += `color: ${color} `; diff --git a/packages/bruno-lang/v2/tests/annotations.spec.js b/packages/bruno-lang/v2/tests/annotations.spec.js index e9907aa97..113fb6e6f 100644 --- a/packages/bruno-lang/v2/tests/annotations.spec.js +++ b/packages/bruno-lang/v2/tests/annotations.spec.js @@ -880,13 +880,82 @@ describe('env pair annotations', () => { expect(output.variables[0]).toEqual({ name: 'API_KEY', value: 'abc123', enabled: true, secret: false }); }); - it('secret vars are unaffected by annotation support', () => { + it('multiple annotations on a secret var', () => { const input = `vars:secret [ - SECRET_KEY + @string + @description('api token') + API_TOKEN ] `; const output = envParser(input); - expect(output.variables).toEqual([{ name: 'SECRET_KEY', value: '', enabled: true, secret: true }]); + expect(output.variables[0].annotations).toEqual([{ name: 'string' }, { name: 'description', value: 'api token' }]); + }); + + it('annotations on multiple secret vars', () => { + const input = `vars:secret [ + env_secret_str, + @number + env_secret_num, + @object + env_secret_obj, + @boolean + env_secret_boolean, + env_secret_new +] +`; + const output = envParser(input); + expect(output.variables).toEqual([ + { name: 'env_secret_str', value: '', enabled: true, secret: true }, + { name: 'env_secret_num', value: '', enabled: true, secret: true, annotations: [{ name: 'number' }] }, + { name: 'env_secret_obj', value: '', enabled: true, secret: true, annotations: [{ name: 'object' }] }, + { name: 'env_secret_boolean', value: '', enabled: true, secret: true, annotations: [{ name: 'boolean' }] }, + { name: 'env_secret_new', value: '', enabled: true, secret: true } + ]); + }); + + it('parseAndSerialise - bru sourced roundtrip check - multiple secret vars with annotations', () => { + const input = `vars:secret [ + env_secret_str, + @number + env_secret_num, + @object + env_secret_obj, + @boolean + env_secret_boolean, + env_secret_new +] +`; + const parsed = envParser(input); + expect(jsonToEnv(parsed)).toEqual(input); + }); + + it('disabled secret var with annotation', () => { + const input = `vars:secret [ + @deprecated + ~OLD_SECRET +] +`; + const output = envParser(input); + expect(output.variables).toEqual([ + { name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] } + ]); + }); + + it('serializeAnnotations in jsonToEnv — disabled secret var with annotation', () => { + const json = { + variables: [{ name: 'OLD_SECRET', value: '', enabled: false, secret: true, annotations: [{ name: 'deprecated' }] }] + }; + const bru = jsonToEnv(json); + expect(bru).toContain('@deprecated\n ~OLD_SECRET'); + }); + + it('parseAndSerialise - json sourced roundtrip check - secret env vars', () => { + const input = { + variables: [{ name: 'SECRET_KEY', value: '', enabled: true, secret: true, annotations: [{ name: 'description', value: 'my secret key' }] }] + }; + const bru = jsonToEnv(input); + const output = envParser(bru); + expect(output).toEqual(input); }); it('serializeAnnotations in jsonToEnv — annotation without value', () => { @@ -942,6 +1011,44 @@ describe('env pair annotations', () => { }); }); +describe('env external secrets', () => { + it('parses an external secrets block into { type, variables }', () => { + const input = `vars:externalsecrets:my-vault { + secret: secret/data/secret + password: secret/data/password +} +`; + const output = envParser(input); + expect(output.externalSecrets).toEqual({ + type: 'my-vault', + variables: [ + { name: 'secret', value: 'secret/data/secret' }, + { name: 'password', value: 'secret/data/password' } + ] + }); + }); + + it('parses an external secrets block with no variables', () => { + const input = `vars:externalsecrets:my-vault { +} +`; + const output = envParser(input); + expect(output.externalSecrets).toEqual({ type: 'my-vault', variables: [] }); + }); + + it('parseAndSerialise - bru sourced roundtrip check - external secrets', () => { + const input = `vars { +} +vars:externalsecrets:my-vault { + secret: secret/data/secret + password: secret/data/password +} +`; + const parsed = envParser(input); + expect(jsonToEnv(parsed)).toEqual(input); + }); +}); + describe('collection pair annotations', () => { it('above-line annotation on a header (collection)', () => { const input = `headers { diff --git a/packages/bruno-schema-types/src/collection/environment.ts b/packages/bruno-schema-types/src/collection/environment.ts index 2ba57bb27..197fba38a 100644 --- a/packages/bruno-schema-types/src/collection/environment.ts +++ b/packages/bruno-schema-types/src/collection/environment.ts @@ -1,5 +1,7 @@ import type { UID, Annotation } from '../common'; +export type EnvironmentVariableDatatype = 'string' | 'number' | 'boolean' | 'object'; + export interface EnvironmentVariable { uid: UID; name?: string | null; @@ -7,13 +9,25 @@ export interface EnvironmentVariable { type: 'text'; enabled?: boolean; secret?: boolean; + datatype?: EnvironmentVariableDatatype; annotations?: Annotation[] | null; } +export interface ExternalSecretVariables { + name: string; + [key: string]: string; +} + +export interface ExternalSecrets { + type: string; + variables: ExternalSecretVariables[]; +} + export interface Environment { uid: UID; name: string; variables: EnvironmentVariable[]; + externalSecrets?: ExternalSecrets; color?: string | null; } diff --git a/packages/bruno-schema-types/src/common/variables.ts b/packages/bruno-schema-types/src/common/variables.ts index a96f4510b..bdb1fd084 100644 --- a/packages/bruno-schema-types/src/common/variables.ts +++ b/packages/bruno-schema-types/src/common/variables.ts @@ -1,6 +1,8 @@ import { Annotation } from './annotation'; import type { UID } from './uid'; +export type VariableDatatype = 'string' | 'number' | 'boolean' | 'object'; + /** * Request-scoped variable entry. */ @@ -11,6 +13,7 @@ export interface Variable { description?: string | null; enabled?: boolean; local?: boolean; + datatype?: VariableDatatype; annotations?: Annotation[] | null; } diff --git a/packages/bruno-schema/src/collections/environmentSchema.spec.js b/packages/bruno-schema/src/collections/environmentSchema.spec.js new file mode 100644 index 000000000..1ac1dc18e --- /dev/null +++ b/packages/bruno-schema/src/collections/environmentSchema.spec.js @@ -0,0 +1,71 @@ +const { uuid } = require('../utils/testUtils'); +const { environmentSchema } = require('./index'); + +const buildVariable = (overrides = {}) => ({ + uid: uuid(), + name: 'env_var', + value: 'value', + type: 'text', + enabled: true, + secret: false, + ...overrides +}); + +const buildEnvironment = (overrides = {}) => ({ + uid: uuid(), + name: 'My Environment', + variables: [], + ...overrides +}); + +describe('Environment Schema Validation', () => { + describe('variable datatype', () => { + it.each(['string', 'number', 'boolean', 'object'])('validates a variable with datatype %s', async (datatype) => { + const env = buildEnvironment({ variables: [buildVariable({ datatype })] }); + + await expect(environmentSchema.validate(env)).resolves.toBeTruthy(); + }); + + it('preserves datatype after validation', async () => { + const env = buildEnvironment({ variables: [buildVariable({ value: '300', datatype: 'number' })] }); + + const validated = await environmentSchema.validate(env); + + expect(validated.variables[0].datatype).toBe('number'); + expect(validated.variables[0].value).toBe('300'); + }); + }); + + describe('external secrets', () => { + it('preserves externalSecrets with provider-specific variable keys after validation', async () => { + const externalSecrets = { + type: 'my-vault', + variables: [ + { name: 'by_value', value: 'secret/data/secret' }, + { name: 'by_path', path: 'secret/data/secret' }, + { name: 'by_secret_name', secretName: 'secret' }, + { name: 'by_vault_name', vaultName: 'secret' } + ] + }; + const env = buildEnvironment({ externalSecrets }); + + const validated = await environmentSchema.validate(env); + + expect(validated.externalSecrets).toEqual(externalSecrets); + }); + + it('validates externalSecrets with no variables', async () => { + const env = buildEnvironment({ externalSecrets: { type: 'my-vault', variables: [] } }); + + await expect(environmentSchema.validate(env)).resolves.toBeTruthy(); + }); + + it('rejects unknown keys on the externalSecrets object', async () => { + const env = buildEnvironment({ + externalSecrets: { type: 'my-vault', variables: [], provider: 'hashicorp' } + }); + + await expect(environmentSchema.validate(env)).rejects.toThrow(); + }); + }); +}); diff --git a/packages/bruno-schema/src/collections/index.js b/packages/bruno-schema/src/collections/index.js index 64170d165..b2bd7259f 100644 --- a/packages/bruno-schema/src/collections/index.js +++ b/packages/bruno-schema/src/collections/index.js @@ -18,16 +18,31 @@ const environmentVariablesSchema = Yup.object({ ) .nullable(), type: Yup.string().oneOf(['text']).required('type is required'), + datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(), enabled: Yup.boolean().defined(), secret: Yup.boolean() }) .noUnknown(true) .strict(); +// External secret variables carry `name` plus a provider-specific reference key +// (path / vaultName / secretName / ...), so unknown keys are allowed through. +const externalSecretVariableSchema = Yup.object({ + name: Yup.string().nullable() +}).strict(); + +const externalSecretsSchema = Yup.object({ + type: Yup.string().nullable(), + variables: Yup.array().of(externalSecretVariableSchema) +}) + .noUnknown(true) + .strict(); + const environmentSchema = Yup.object({ uid: uidSchema, name: Yup.string().min(1).required('name is required'), variables: Yup.array().of(environmentVariablesSchema).required('variables are required'), + externalSecrets: externalSecretsSchema.nullable().optional(), color: Yup.string().nullable().optional(), pathname: Yup.string().nullable() }) @@ -96,6 +111,7 @@ const varsSchema = Yup.object({ name: Yup.string().nullable(), value: Yup.string().nullable(), description: Yup.string().nullable(), + datatype: Yup.string().oneOf(['string', 'number', 'boolean', 'object']).nullable(), // Optional annotations on variables annotations: Yup.array() .of( diff --git a/packages/bruno-schema/src/collections/requestSchema.spec.js b/packages/bruno-schema/src/collections/requestSchema.spec.js index 258b1138f..8a17073ff 100644 --- a/packages/bruno-schema/src/collections/requestSchema.spec.js +++ b/packages/bruno-schema/src/collections/requestSchema.spec.js @@ -18,6 +18,33 @@ describe('Request Schema Validation', () => { expect(isValid).toBeTruthy(); }); + it('request schema must validate successfully - vars with datatype', async () => { + const request = { + url: 'https://restcountries.com/v2/alpha/in', + method: 'GET', + headers: [], + params: [], + body: { + mode: 'none' + }, + vars: { + req: [ + { uid: uuid(), name: 'var_num', value: '300', datatype: 'number', enabled: true, local: false }, + { uid: uuid(), name: 'var_bool', value: 'true', datatype: 'boolean', enabled: true, local: false }, + { uid: uuid(), name: 'var_obj', value: '{"scope":"req"}', datatype: 'object', enabled: true, local: false }, + { uid: uuid(), name: 'var_str', value: 'plain', enabled: true, local: false } + ], + res: [] + } + }; + + const validated = await requestSchema.validate(request); + expect(validated.vars.req[0].datatype).toBe('number'); + expect(validated.vars.req[1].datatype).toBe('boolean'); + expect(validated.vars.req[2].datatype).toBe('object'); + expect(validated.vars.req[3].datatype).toBeUndefined(); + }); + it('request schema must validate successfully - custom method', async () => { const request = { url: 'https://restcountries.com/v2/alpha/in',