feat(#1460): string interpolation

This commit is contained in:
Anoop M D 2024-01-27 14:07:49 +05:30
parent aed1b41da6
commit 9ad265e74f
15 changed files with 394 additions and 2 deletions

54
package-lock.json generated
View File

@ -9,6 +9,7 @@
"packages/bruno-app", "packages/bruno-app",
"packages/bruno-electron", "packages/bruno-electron",
"packages/bruno-cli", "packages/bruno-cli",
"packages/bruno-common",
"packages/bruno-schema", "packages/bruno-schema",
"packages/bruno-query", "packages/bruno-query",
"packages/bruno-js", "packages/bruno-js",
@ -21,6 +22,7 @@
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0", "@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1", "@playwright/test": "^1.27.1",
"@types/jest": "^29.5.11",
"fs-extra": "^11.1.1", "fs-extra": "^11.1.1",
"husky": "^8.0.3", "husky": "^8.0.3",
"jest": "^29.2.0", "jest": "^29.2.0",
@ -5240,6 +5242,16 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"node_modules/@types/jest": {
"version": "29.5.11",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz",
"integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==",
"dev": true,
"dependencies": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"dev": true, "dev": true,
@ -5381,6 +5393,10 @@
"resolved": "packages/bruno-cli", "resolved": "packages/bruno-cli",
"link": true "link": true
}, },
"node_modules/@usebruno/common": {
"resolved": "packages/bruno-common",
"link": true
},
"node_modules/@usebruno/graphql-docs": { "node_modules/@usebruno/graphql-docs": {
"resolved": "packages/bruno-graphql-docs", "resolved": "packages/bruno-graphql-docs",
"link": true "link": true
@ -18144,6 +18160,21 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"packages/bruno-common": {
"name": "@usebruno/common",
"version": "0.1.0",
"license": "MIT",
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
}
},
"packages/bruno-electron": { "packages/bruno-electron": {
"name": "bruno", "name": "bruno",
"version": "v1.6.1", "version": "v1.6.1",
@ -22287,6 +22318,16 @@
"@types/istanbul-lib-report": "*" "@types/istanbul-lib-report": "*"
} }
}, },
"@types/jest": {
"version": "29.5.11",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz",
"integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==",
"dev": true,
"requires": {
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"@types/json-schema": { "@types/json-schema": {
"version": "7.0.11", "version": "7.0.11",
"dev": true "dev": true
@ -22628,6 +22669,19 @@
} }
} }
}, },
"@usebruno/common": {
"version": "file:packages/bruno-common",
"requires": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
}
},
"@usebruno/graphql-docs": { "@usebruno/graphql-docs": {
"version": "file:packages/bruno-graphql-docs", "version": "file:packages/bruno-graphql-docs",
"requires": { "requires": {

View File

@ -5,6 +5,7 @@
"packages/bruno-app", "packages/bruno-app",
"packages/bruno-electron", "packages/bruno-electron",
"packages/bruno-cli", "packages/bruno-cli",
"packages/bruno-common",
"packages/bruno-schema", "packages/bruno-schema",
"packages/bruno-query", "packages/bruno-query",
"packages/bruno-js", "packages/bruno-js",
@ -18,18 +19,20 @@
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@jest/globals": "^29.2.0", "@jest/globals": "^29.2.0",
"@playwright/test": "^1.27.1", "@playwright/test": "^1.27.1",
"@types/jest": "^29.5.11",
"fs-extra": "^11.1.1",
"husky": "^8.0.3", "husky": "^8.0.3",
"jest": "^29.2.0", "jest": "^29.2.0",
"pretty-quick": "^3.1.3", "pretty-quick": "^3.1.3",
"randomstring": "^1.2.2", "randomstring": "^1.2.2",
"ts-jest": "^29.0.5", "ts-jest": "^29.0.5"
"fs-extra": "^11.1.1"
}, },
"scripts": { "scripts": {
"dev:web": "npm run dev --workspace=packages/bruno-app", "dev:web": "npm run dev --workspace=packages/bruno-app",
"build:web": "npm run build --workspace=packages/bruno-app", "build:web": "npm run build --workspace=packages/bruno-app",
"prettier:web": "npm run prettier --workspace=packages/bruno-app", "prettier:web": "npm run prettier --workspace=packages/bruno-app",
"dev:electron": "npm run dev --workspace=packages/bruno-electron", "dev:electron": "npm run dev --workspace=packages/bruno-electron",
"build:bruno-common": "npm run build --workspace=packages/bruno-common",
"build:bruno-query": "npm run build --workspace=packages/bruno-query", "build:bruno-query": "npm run build --workspace=packages/bruno-query",
"build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs", "build:graphql-docs": "npm run build --workspace=packages/bruno-graphql-docs",
"build:electron": "node ./scripts/build-electron.js", "build:electron": "node ./scripts/build-electron.js",

22
packages/bruno-common/.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# dependencies
node_modules
yarn.lock
pnpm-lock.yaml
package-lock.json
.pnp
.pnp.js
# testing
coverage
# production
dist
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node'
};

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Anoop M D, Anusree P S and Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,33 @@
{
"name": "@usebruno/common",
"version": "0.1.0",
"license": "MIT",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"src",
"package.json"
],
"scripts": {
"clean": "rimraf dist",
"test": "jest",
"prebuild": "npm run clean",
"build": "rollup -c",
"prepack": "npm run test && npm run build"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^23.0.2",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^9.0.2",
"rollup": "3.2.5",
"rollup-plugin-dts": "^5.0.0",
"rollup-plugin-peer-deps-external": "^2.2.4",
"rollup-plugin-terser": "^7.0.2",
"typescript": "^4.8.4"
},
"overrides": {
"rollup": "3.2.5"
}
}

View File

@ -0,0 +1,9 @@
# bruno-common
A collection of common utilities used across Bruno App, Electron and CLI packages.
### Publish to Npm Registry
```bash
npm publish --access=public
```

View File

@ -0,0 +1,40 @@
const { nodeResolve } = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const typescript = require('@rollup/plugin-typescript');
const dts = require('rollup-plugin-dts');
const { terser } = require('rollup-plugin-terser');
const peerDepsExternal = require('rollup-plugin-peer-deps-external');
const packageJson = require('./package.json');
module.exports = [
{
input: 'src/index.ts',
output: [
{
file: packageJson.main,
format: 'cjs',
sourcemap: true
},
{
file: packageJson.module,
format: 'esm',
sourcemap: true
}
],
plugins: [
peerDepsExternal(),
nodeResolve({
extensions: ['.css']
}),
commonjs(),
typescript({ tsconfig: './tsconfig.json' }),
terser()
]
},
{
input: 'dist/esm/index.d.ts',
output: [{ file: 'dist/index.d.ts', format: 'esm' }],
plugins: [dts.default()]
}
];

View File

@ -0,0 +1,5 @@
import interpolate from './interpolate';
export default {
interpolate
};

View File

@ -0,0 +1,16 @@
String Interpolation
### Goal
Today our interlation logic is duplicated across multiple packages.
The goal is to centralize a single source of truth for all interpolation logic.
### Considerations
- We want to be flexible in terms of key naming conventions.
- We plan to support Nested environments in the future.
### Moving away from handlebars
I think its time to move away from handlebars.
We don't need the full power of handlebars and write a custom interpolation function that meets our needs.

View File

@ -0,0 +1,84 @@
import interpolate from './index';
describe('interpolate', () => {
it('should replace placeholders with values from the object', () => {
const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
const inputObject = {
'user.name': 'Bruno',
user: {
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe('Hello, my name is Bruno and I am 4 years old');
});
it('should handle missing values by leaving the placeholders unchanged using {{}} as delimiters', () => {
const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
const inputObject = {
user: {
name: 'Bruno'
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe('Hello, my name is Bruno and I am {{user.age}} years old');
});
it('should handle all valid keys', () => {
const inputObject = {
user: {
full_name: 'Bruno',
age: 4,
'fav-food': ['egg', 'meat'],
'want.attention': true
}
};
const inputStr = `
Hi, I am {{user.full_name}},
I am {{user.age}} years old.
My favorite food is {{user.fav-food[0]}} and {{user.fav-food[1]}}.
I like attention: {{user.want.attention}}
`;
const expectedStr = `
Hi, I am Bruno,
I am 4 years old.
My favorite food is egg and meat.
I like attention: true
`;
const result = interpolate(inputStr, inputObject);
expect(result).toBe(expectedStr);
});
it('should strictly match the keys (whitespace matters)', () => {
const inputString = 'Hello, my name is {{ user.name }} and I am {{user.age}} years old';
const inputObject = {
'user.name': 'Bruno',
user: {
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe('Hello, my name is {{ user.name }} and I am 4 years old');
});
it('should give precedence to the last key in case of duplicates', () => {
const inputString = 'Hello, my name is {{user.name}} and I am {{user.age}} years old';
const inputObject = {
'user.name': 'Bruno',
user: {
name: 'Not Bruno',
age: 4
}
};
const result = interpolate(inputString, inputObject);
expect(result).toBe('Hello, my name is Not Bruno and I am 4 years old');
});
});

View File

@ -0,0 +1,31 @@
/**
* The interpolation function expects a string with placeholders and an object with the values to replace the placeholders.
* The keys passed can have dot notation too.
*
* Ex: interpolate('Hello, my name is ${user.name} and I am ${user.age} years old', {
* "user.name": "Bruno",
* "user": {
* "age": 4
* }
* });
* Output: Hello, my name is Bruno and I am 4 years old
*/
import { flattenObject } from '../utils';
const interpolate = (str: string, obj: Record<string, any>): string => {
if (!str || typeof str !== 'string' || !obj || typeof obj !== 'object') {
return str;
}
const patternRegex = /\{\{([^}]+)\}\}/g;
const flattenedObj = flattenObject(obj);
const result = str.replace(patternRegex, (match, placeholder) => {
const replacement = flattenedObj[placeholder];
return replacement !== undefined ? replacement : match;
});
return result;
};
export default interpolate;

View File

@ -0,0 +1,39 @@
import { flattenObject } from './index';
describe('flattenObject', () => {
it('should flatten a simple object', () => {
const input = { a: 1, b: { c: 2, d: { e: 3 } } };
const output = flattenObject(input);
expect(output).toEqual({ a: 1, 'b.c': 2, 'b.d.e': 3 });
});
it('should flatten an object with arrays', () => {
const input = { a: 1, b: { c: [2, 3, 4], d: { e: 5 } } };
const output = flattenObject(input);
expect(output).toEqual({ a: 1, 'b.c[0]': 2, 'b.c[1]': 3, 'b.c[2]': 4, 'b.d.e': 5 });
});
it('should flatten an object with arrays having objects', () => {
const input = { a: 1, b: { c: [{ d: 2 }, { e: 3 }], f: { g: 4 } } };
const output = flattenObject(input);
expect(output).toEqual({ a: 1, 'b.c[0].d': 2, 'b.c[1].e': 3, 'b.f.g': 4 });
});
it('should handle null values', () => {
const input = { a: 1, b: { c: null, d: { e: 3 } } };
const output = flattenObject(input);
expect(output).toEqual({ a: 1, 'b.c': null, 'b.d.e': 3 });
});
it('should handle an empty object', () => {
const input = {};
const output = flattenObject(input);
expect(output).toEqual({});
});
it('should handle an object with nested empty objects', () => {
const input = { a: { b: {}, c: { d: {} } } };
const output = flattenObject(input);
expect(output).toEqual({});
});
});

View File

@ -0,0 +1,11 @@
export const flattenObject = (obj: Record<string, any>, parentKey: string = ''): Record<string, any> => {
return Object.entries(obj).reduce((acc: Record<string, any>, [key, value]: [string, any]) => {
const newKey = parentKey ? (Array.isArray(obj) ? `${parentKey}[${key}]` : `${parentKey}.${key}`) : key;
if (typeof value === 'object' && value !== null) {
Object.assign(acc, flattenObject(value, newKey));
} else {
acc[newKey] = value;
}
return acc;
}, {});
};

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES6",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"jsx": "react",
"module": "ESNext",
"declaration": true,
"declarationDir": "types",
"sourceMap": true,
"outDir": "dist",
"moduleResolution": "node",
"emitDeclarationOnly": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"exclude": ["dist", "node_modules", "tests"]
}