├── .eslintignore ├── .forceignore ├── .gitignore ├── .husky └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── config └── project-scratch-def.json ├── force-app └── main │ └── default │ └── classes │ ├── JSONPath.cls │ ├── JSONPath.cls-meta.xml │ ├── JSONPathTest.cls │ └── JSONPathTest.cls-meta.xml ├── jest.config.js ├── package.json ├── sfdx-project.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | **/lwc/**/*.css 2 | **/lwc/**/*.html 3 | **/lwc/**/*.json 4 | **/lwc/**/*.svg 5 | **/lwc/**/*.xml 6 | **/aura/**/*.auradoc 7 | **/aura/**/*.cmp 8 | **/aura/**/*.css 9 | **/aura/**/*.design 10 | **/aura/**/*.evt 11 | **/aura/**/*.json 12 | **/aura/**/*.svg 13 | **/aura/**/*.tokens 14 | **/aura/**/*.xml 15 | **/aura/**/*.app 16 | .sfdx 17 | -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running force:source:push, force:source:pull, and force:source:status 2 | # More information: https://developer.salesforce.com/docs/atlas.en-us.sfdx_dev.meta/sfdx_dev/sfdx_dev_exclude_source.htm 3 | # 4 | 5 | package.xml 6 | 7 | # LWC configuration files 8 | **/jsconfig.json 9 | **/.eslintrc.json 10 | 11 | # LWC Jest 12 | **/__tests__/** -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # This file is used for Git repositories to specify intentionally untracked files that Git should ignore. 2 | # If you are not using git, you can delete this file. For more information see: https://git-scm.com/docs/gitignore 3 | # For useful gitignore templates see: https://github.com/github/gitignore 4 | 5 | # Salesforce cache 6 | .sf/ 7 | .sfdx/ 8 | .localdevserver/ 9 | deploy-options.json 10 | 11 | # LWC VSCode autocomplete 12 | **/lwc/jsconfig.json 13 | 14 | # LWC Jest coverage reports 15 | coverage/ 16 | 17 | # Logs 18 | logs 19 | *.log 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | # Dependency directories 25 | node_modules/ 26 | # since yarn.lock is checked in 27 | package-lock.json 28 | 29 | # Eslint cache 30 | .eslintcache 31 | 32 | # MacOS system files 33 | .DS_Store 34 | 35 | # Windows system files 36 | Thumbs.db 37 | ehthumbs.db 38 | [Dd]esktop.ini 39 | $RECYCLE.BIN/ 40 | 41 | # Local environment variables 42 | .env -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npm run precommit -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # List files or directories below to ignore them when running prettier 2 | # More information: https://prettier.io/docs/en/ignore.html 3 | # 4 | 5 | **/staticresources/** 6 | .localdevserver 7 | .sfdx 8 | .vscode 9 | 10 | coverage/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "overrides": [ 4 | { 5 | "files": "**/lwc/**/*.html", 6 | "options": { "parser": "lwc" } 7 | }, 8 | { 9 | "files": "*.{cmp,page,component}", 10 | "options": { "parser": "html" } 11 | }, 12 | { 13 | "files": "*.{trigger,cls}", 14 | "options": { "parser": "apex", "tabWidth": 4 } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "salesforce.salesforcedx-vscode", 4 | "redhat.vscode-xml", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode", 7 | "financialforce.lana" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Launch Apex Replay Debugger", 9 | "type": "apex-replay", 10 | "request": "launch", 11 | "logFile": "${command:AskForLogFileName}", 12 | "stopOnEntry": true, 13 | "trace": true 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": true, 4 | "**/bower_components": true, 5 | "**/.sfdx": true 6 | }, 7 | "salesforcedx-vscode-core.show-cli-success-msg": false 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dogeforce 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apex JSON Path 2 | 3 | This repository contains a minimum implementation of the [JSON Path Syntax](https://support.smartbear.com/alertsite/docs/monitors/api/endpoint/jsonpath.html). With this you can access data from a JSON string using a path specified by another string. 4 | 5 | Might come in handy with integrations, specially, where one might have to read data from a JSON payload according to some specifications from data or metadata instead of deserializing the whole thing to an Apex type. 6 | 7 | ## Usage 8 | 9 | To get an attribute value: 10 | 11 | ```apex 12 | JSONPath j = new JSONPath('{"name":"John","company":{"name":"Company"}}'); 13 | 14 | String companyName = j.get('$.company.name'); 15 | 16 | System.assertEquals('Company', companyName, 'Wrong company name.'); 17 | ``` 18 | 19 | It works for returning entire objects. So you could use `$.company` to get the `Object` that contains the company data (then you could cast it to a `Map` and access the `name` from there if you wanted to). This is also true for returning inner lists. 20 | 21 | ```apex 22 | JSONPath jpListNested = new JSONPath('[{"attr":[{"name":"John"},{"name":"Mary"}]}]'); 23 | 24 | System.assertEquals( 25 | 'Mary', 26 | jpListNested.get('$[0].attr[1].name'), 27 | 'Incorrect data.' 28 | ); 29 | ``` 30 | 31 | Also works for returning specific attributes from inner lists. So if the JSON payload is a list or the object contains a list then it is reachable using the `[]` or `[*]` syntax: 32 | 33 | ```apex 34 | JSONPath jpAttributeListFromObjectList = new JSONPath( 35 | '[{"name":"John"},{"name":"Mary"}]' 36 | ); 37 | List names = (List) jpAttributeListFromObjectList.get('$[*].name'); 38 | // names[0] = John and names[1] = 'Mary' 39 | 40 | JSONPath jpAttributeListFromInnerObjectList = new JSONPath( 41 | '{"people":[{"name":"John"},{"name":"Mary"}]}}' 42 | ); 43 | names = (List) jpAttributeListFromInnerObjectList.get('$.people[*].name'); 44 | // names[0] = John and names[1] = 'Mary' 45 | ``` 46 | 47 | ### List functions 48 | 49 | The following functions are available for usage with list attributes: 50 | 51 | 1. `min` to get the minimum value (returns a double); 52 | 1. `max` to get the maximum value (returns a double); 53 | 1. `avg` to get the average value (returns a double); 54 | 1. `sum` to get the sum of values (returns a double); 55 | 1. `size` and `length` to get the quantity of values in the list (returns an integer); 56 | 1. `empty` to get a boolean indicating if the list is empty; 57 | 58 | Usage: 59 | 60 | ```apex 61 | JSONPath arrayFunctions = new JSONPath('{"numbers":[1, 2, 3, 40]}'); 62 | 63 | arrayFunctions.get('$.numbers.empty()'); // false 64 | arrayFunctions.get('$.numbers.length()'); // 4 65 | arrayFunctions.get('$.numbers.size()'); // 4 66 | arrayFunctions.get('$.numbers.min()'); // 1 67 | arrayFunctions.get('$.numbers.max()'); // 40 68 | arrayFunctions.get('$.numbers.avg()'); // 11.5 69 | ``` 70 | -------------------------------------------------------------------------------- /config/project-scratch-def.json: -------------------------------------------------------------------------------- 1 | { 2 | "orgName": "JSONPath", 3 | "edition": "Developer", 4 | "features": ["EnableSetPasswordInApi"], 5 | "settings": { 6 | "lightningExperienceSettings": { 7 | "enableS1DesktopEnabled": true 8 | }, 9 | "mobileSettings": { 10 | "enableS1EncryptedStoragePref2": false 11 | } 12 | }, 13 | "language": "en_US" 14 | } 15 | -------------------------------------------------------------------------------- /force-app/main/default/classes/JSONPath.cls: -------------------------------------------------------------------------------- 1 | public class JSONPath { 2 | private String raw; 3 | private Object data; 4 | 5 | public JSONPath(String rawJson) { 6 | this.raw = rawJson; 7 | this.data = JSON.deserializeUntyped(rawJson); 8 | } 9 | 10 | public Decimal getNumber(String path) { 11 | return (Decimal) this.get(path); 12 | } 13 | 14 | public Date getDate(String path) { 15 | return Date.valueOf(this.getString(path)); 16 | } 17 | 18 | public Datetime getDatetime(String path) { 19 | return Datetime.valueOf(this.getString(path)); 20 | } 21 | 22 | public Object getDeserializedObject(String path, Type typeToDeserialize) { 23 | return JSON.deserialize(JSON.serialize(this.get(path)), typeToDeserialize); 24 | } 25 | 26 | public String getString(String path) { 27 | return (String) this.get(path); 28 | } 29 | 30 | public Object get(String path) { 31 | try { 32 | Object o = this.get(path.removeStart('$'), this.data); 33 | return o; 34 | } catch (Exception e) { 35 | return null; 36 | } 37 | } 38 | 39 | private Object get(String path, Object data) { 40 | Boolean isObject = data instanceof Map; 41 | Boolean isList = data instanceof List; 42 | 43 | List levels = path.removeStart('.').split('\\.'); 44 | String currentLevel = levels.remove(0); 45 | 46 | if (isObject) { 47 | return handleObject(currentLevel, levels, data); 48 | } 49 | 50 | if (isList) { 51 | return handleList(currentLevel, levels, data); 52 | } 53 | 54 | return null; 55 | } 56 | 57 | private Object handleObject( 58 | String currentLevel, 59 | List levels, 60 | Object data 61 | ) { 62 | Map d = (Map) data; 63 | 64 | if (currentLevel.endsWith(']')) { 65 | return this.get( 66 | ('[' + 67 | currentLevel.reverse().substringBetween(']', '[').reverse() + 68 | '].' + 69 | String.join(levels, '.')), 70 | d.get(currentLevel.split('\\[').remove(0)) 71 | ); 72 | } 73 | 74 | return levels.size() == 0 75 | ? d.get(currentLevel) 76 | : this.get(String.join(levels, '.'), d.get(currentLevel)); 77 | } 78 | 79 | private Object handleList( 80 | String currentLevel, 81 | List levels, 82 | Object data 83 | ) { 84 | List dataList = (List) data; 85 | if (currentLevel.startsWith('[]') || currentLevel.startsWith('[*]')) { 86 | if (levels.size() > 0) { 87 | List result = new List(); 88 | for (Object objectInstance : dataList) { 89 | result.add( 90 | this.get(String.join(levels, '.'), objectInstance) 91 | ); 92 | } 93 | return result; 94 | } 95 | return dataList; 96 | } 97 | 98 | if (currentLevel.endsWith('()')) { 99 | switch on currentLevel.removeEnd('()') { 100 | when 'min' { 101 | return this.getMin(dataList); 102 | } 103 | when 'max' { 104 | return this.getMax(dataList); 105 | } 106 | when 'avg' { 107 | return this.getAvg(dataList); 108 | } 109 | when 'sum' { 110 | return this.getSum(dataList); 111 | } 112 | when 'length', 'size' { 113 | return dataList.size(); 114 | } 115 | when 'empty' { 116 | return dataList.isEmpty(); 117 | } 118 | when else { 119 | throw new JSONPath.JSONPathException( 120 | 'Non-existing function "' + 121 | currentLevel.removeEnd('()') + 122 | '".' 123 | ); 124 | } 125 | } 126 | } 127 | 128 | Integer index = Integer.valueOf( 129 | currentLevel.substringBetween('[', ']') 130 | ); 131 | 132 | return levels.size() == 0 133 | ? dataList.get(index) 134 | : this.get(String.join(levels, '.'), dataList.get(index)); 135 | } 136 | 137 | private Double getMin(List numbers) { 138 | return getMinOrMax(numbers, true); 139 | } 140 | 141 | private Double getMax(List numbers) { 142 | return getMinOrMax(numbers, false); 143 | } 144 | 145 | private Double getMinOrMax(List numbers, Boolean isMin) { 146 | Double result; 147 | 148 | for (Object obj : numbers) { 149 | Double d = (Double) obj; 150 | if (result == null) { 151 | result = d; 152 | continue; 153 | } 154 | 155 | if (isMin) { 156 | if (d < result) { 157 | result = d; 158 | } 159 | } else { 160 | if (d > result) { 161 | result = d; 162 | } 163 | } 164 | } 165 | 166 | return result; 167 | } 168 | 169 | private Double getAvg(List numbers) { 170 | return getSum(numbers) / numbers.size(); 171 | } 172 | 173 | private Double getSum(List numbers) { 174 | Double sum = 0; 175 | 176 | for (Object obj : numbers) { 177 | sum += obj != null ? (Double) obj : 0; 178 | } 179 | 180 | return sum; 181 | } 182 | 183 | public class JSONPathException extends Exception { 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /force-app/main/default/classes/JSONPath.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/JSONPathTest.cls: -------------------------------------------------------------------------------- 1 | @IsTest 2 | private class JSONPathTest { 3 | @IsTest 4 | static void test() { 5 | JSONPath jpObject = new JSONPath( 6 | '{"name":"John","company":{"name":"Company"}}' 7 | ); 8 | System.assertEquals('John', jpObject.get('$.name'), 'Incorrect data.'); 9 | System.assertEquals( 10 | 'Company', 11 | jpObject.get('$.company.name'), 12 | 'Incorrect data.' 13 | ); 14 | JSONPath jpList = new JSONPath( 15 | '[{"name":"John","company":{"name":"Company"}}]' 16 | ); 17 | System.assertEquals('John', jpList.get('$[0].name'), 'Incorrect data.'); 18 | System.assert( 19 | jpList.get('$[*]') instanceof List, 20 | 'Should be a list.' 21 | ); 22 | System.assert( 23 | jpList.get('$[0]') instanceof Map, 24 | 'Should be an object.' 25 | ); 26 | JSONPath jpListNested = new JSONPath( 27 | '[{"attr":[{"name":"John"},{"name":"Mary"}]}]' 28 | ); 29 | System.assertEquals( 30 | 'Mary', 31 | jpListNested.get('$[0].attr[1].name'), 32 | 'Incorrect data.' 33 | ); 34 | System.assert( 35 | jpListNested.get('$[*].name') instanceof List, 36 | 'Should have returned a list.' 37 | ); 38 | JSONPath jpAttributeListFromObjectList = new JSONPath( 39 | '[{"name":"John"},{"name":"Mary"}]' 40 | ); 41 | List names = (List) jpAttributeListFromObjectList.get( 42 | '$[*].name' 43 | ); 44 | System.assert( 45 | names[0] == 'John' && 46 | names[1] == 'Mary', 47 | 'Should have returned a list of names.' 48 | ); 49 | JSONPath jpAttributeListFromInnerObjectList = new JSONPath( 50 | '{"people":[{"name":"John"},{"name":"Mary"}]}}' 51 | ); 52 | names = (List) jpAttributeListFromInnerObjectList.get( 53 | '$.people[*].name' 54 | ); 55 | System.assert( 56 | names[0] == 'John' && 57 | names[1] == 'Mary', 58 | 'Should have returned a list of names.' 59 | ); 60 | JSONPath invalidPath = new JSONPath( 61 | '{"messages":["Hello, world!", "Goodbye, world!"]}' 62 | ); 63 | System.assertEquals( 64 | null, 65 | invalidPath.get('$.messages[-1]'), 66 | 'Should have returned null for invalid path.' 67 | ); 68 | 69 | JSONPath twoDigitsList = new JSONPath( 70 | '{"integers":[0,1,2,3,4,5,6,7,8,9,10]}' 71 | ); 72 | System.assertEquals( 73 | 10, 74 | twoDigitsList.get('$.integers[10]'), 75 | 'Should have returned 10.' 76 | ); 77 | 78 | JSONPath testArrayFunctions = new JSONPath('{"numbers":[1, 2, 3, 40]}'); 79 | System.assertEquals( 80 | false, 81 | testArrayFunctions.get('$.numbers.empty()'), 82 | 'Incorrect result for "empty".' 83 | ); 84 | System.assertEquals( 85 | 4, 86 | testArrayFunctions.get('$.numbers.length()'), 87 | 'Incorrect result for "length".' 88 | ); 89 | System.assertEquals( 90 | 4, 91 | testArrayFunctions.get('$.numbers.size()'), 92 | 'Incorrect result for "size".' 93 | ); 94 | System.assertEquals( 95 | 1, 96 | testArrayFunctions.get('$.numbers.min()'), 97 | 'Incorrect result for "min".' 98 | ); 99 | System.assertEquals( 100 | 40, 101 | testArrayFunctions.get('$.numbers.max()'), 102 | 'Incorrect result for "max".' 103 | ); 104 | System.assertEquals( 105 | 11.5, 106 | testArrayFunctions.get('$.numbers.avg()'), 107 | 'Incorrect result for "avg".' 108 | ); 109 | } 110 | 111 | @IsTest 112 | static void usesHelperFunctionsToReturnStrongTypes() { 113 | String exampleJson = '{ "number": 1, "aDate": "2020-01-01", "aDatetime": "2020-01-01 00:00:000Z", "text": "hi", "exampleObject": { "myProp": 1 } }'; 114 | 115 | JSONPath path = new JSONPath(exampleJson); 116 | 117 | System.assertEquals(1, path.getNumber('$.number')); 118 | System.assertEquals(Date.newInstance(2020, 1, 1), path.getDate('$.aDate')); 119 | System.assertEquals(Datetime.newInstance(2020, 1, 1), path.getDatetime('$.aDatetime')); 120 | System.assertEquals('hi', path.getString('$.text')); 121 | System.assertEquals(1, ((ExampleObject) path.getDeserializedObject('$.exampleObject', ExampleObject.class)).myProp); 122 | } 123 | 124 | public class ExampleObject { 125 | public Integer myProp; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /force-app/main/default/classes/JSONPathTest.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 54.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { jestConfig } = require('@salesforce/sfdx-lwc-jest/config'); 2 | 3 | module.exports = { 4 | ...jestConfig, 5 | modulePathIgnorePatterns: ['/.localdevserver'] 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "salesforce-app", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Salesforce App", 6 | "scripts": { 7 | "lint": "eslint **/{aura,lwc}/**", 8 | "test": "npm run test:unit", 9 | "test:unit": "sfdx-lwc-jest", 10 | "test:unit:watch": "sfdx-lwc-jest --watch", 11 | "test:unit:debug": "sfdx-lwc-jest --debug", 12 | "test:unit:coverage": "sfdx-lwc-jest --coverage", 13 | "prettier": "prettier --write \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 14 | "prettier:verify": "prettier --list-different \"**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}\"", 15 | "postinstall": "husky install", 16 | "precommit": "lint-staged" 17 | }, 18 | "devDependencies": { 19 | "@lwc/eslint-plugin-lwc": "^1.1.2", 20 | "@prettier/plugin-xml": "^2.0.1", 21 | "@salesforce/eslint-config-lwc": "^3.2.3", 22 | "@salesforce/eslint-plugin-aura": "^2.0.0", 23 | "@salesforce/eslint-plugin-lightning": "^1.0.0", 24 | "@salesforce/sfdx-lwc-jest": "^1.1.0", 25 | "eslint": "^8.11.0", 26 | "eslint-plugin-import": "^2.25.4", 27 | "eslint-plugin-jest": "^26.1.2", 28 | "husky": "^7.0.4", 29 | "lint-staged": "^12.3.7", 30 | "prettier": "^2.6.2", 31 | "prettier-plugin-apex": "^1.10.0" 32 | }, 33 | "lint-staged": { 34 | "**/*.{cls,cmp,component,css,html,js,json,md,page,trigger,xml,yaml,yml}": [ 35 | "prettier --write" 36 | ], 37 | "**/{aura,lwc}/**": [ 38 | "eslint" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true 6 | } 7 | ], 8 | "name": "ApexJsonPath", 9 | "namespace": "", 10 | "sfdcLoginUrl": "https://login.salesforce.com", 11 | "sourceApiVersion": "54.0" 12 | } 13 | --------------------------------------------------------------------------------