A lightweight, config-driven Java library for transforming one JSON structure into another using JSONPath expressions and pluggable custom transformation logic.
No code changes are needed to add new field mappings — everything is driven by simple JSON config files.
- 🚀 Zero code changes - Add new transformations via JSON config files
- 📝 JSONPath-based - Powerful path expressions for flexible field extraction
- 🔌 Pluggable transformers - Extend with custom transformation logic
- 🎯 Multiple config types - Support different input formats with separate configs
- ⚡ Lightweight - Minimal dependencies, fast execution
- 🛡️ Type-safe - Supports string, array, object, boolean, and integer types
- 🔄 Fallback paths - Graceful handling of missing fields
- 📦 Flexible deployment - Classpath or file-system config loading
- Prerequisites
- Installation
- How it works
- Quickstart
- Providing your own config (library consumers)
- Config format reference
- Custom transformer guide
- Multi-config example: normalising two webhook formats
- Error handling
- Thread safety
- Troubleshooting
- Building the JAR
- Running the tests
- Contributing
- License
- Java 11 or higher (tested with Java 11, 17, and 21)
- One of the following build tools:
- Maven 3.6+ (also required for building from source)
- Gradle 7.0+
Note: Ensure
JAVA_HOMEpoints to a Java 11+ JDK before running Maven. On macOS with multiple JDK installations:export JAVA_HOME=$(/usr/libexec/java_home -v 11)
Add the following dependency to your pom.xml:
<dependency>
<groupId>com.intuit.json2jsontransformer</groupId>
<artifactId>json2jsontransformer</artifactId>
<version>1.0.0</version>
</dependency>Note: This adds the regular JAR which requires transitive dependencies. If you prefer a single JAR with all dependencies included (fat JAR), see the Building the JAR section.
Add the following to your build.gradle:
dependencies {
implementation 'com.intuit.json2jsontransformer:json2jsontransformer:1.0.0'
}Or for Kotlin DSL (build.gradle.kts):
dependencies {
implementation("com.intuit.json2jsontransformer:json2jsontransformer:1.0.0")
}If the library is not yet published to Maven Central, you can build it locally:
git clone https://github.com/intuit/json2jsontransformer.git
cd json2jsontransformer
mvn clean install -DskipTestsinstall (not package) is required — it copies the JAR into your local ~/.m2 repository so other Maven projects on the same machine can resolve the dependency. Then add the coordinates to your consumer project's pom.xml as shown in the Maven section above.
src/main/resources/
config/
<configType>/ ← one subdirectory per config type
*.json ← one or more JSON config files
At startup, JsonSchemaLoader scans every subdirectory under config/ and registers it as a named config type. At runtime you call transformJson(inputJson, "configType") to apply those mappings.
Each config file declares a list of output fields. For every field you specify:
- which JSONPath(s) to read from the input
- the output type (
string,array,object,boolean,integer) - optional fallback paths tried when the primary path is absent
- an optional default value used when everything else returns null
- an optional custom transformation class for complex logic
1. Add the dependency (see Installation above)
2. Create a config file at src/main/resources/config/default/my_config.json:
{
"parser_configs": [
{
"field_name": "userId",
"is_mandatory": true,
"target_schema": {
"type": "string",
"mappings": [{ "json_paths": ["$.user.id"] }]
}
},
{
"field_name": "email",
"is_mandatory": false,
"target_schema": {
"type": "string",
"mappings": [{ "json_paths": ["$.user.email"] }]
}
}
]
}3. Transform JSON in code:
JsonSchemaLoader declares checked exceptions — handle them in your method:
// Option A — propagate (suits main methods and tests)
public static void main(String[] args) throws Exception {
JsonTransformer transformer = new JsonTransformer(new JsonSchemaLoader());
String inputJson = "{\"user\": {\"id\": \"u-001\", \"email\": \"alex@example.com\"}}";
String outputJson = transformer.transformJson(inputJson, "default");
// → {"email":"alex@example.com","userId":"u-001"}
}
// Option B — catch explicitly
try {
JsonTransformer transformer = new JsonTransformer(new JsonSchemaLoader());
String inputJson = "{\"user\": {\"id\": \"u-001\", \"email\": \"alex@example.com\"}}";
String outputJson = transformer.transformJson(inputJson, "default");
} catch (URISyntaxException | IOException e) {
// config loading failed or input JSON is malformed
}The library JAR ships with no bundled configs. You supply the config files that describe your own field mappings. There are three ways to do this.
Exception handling: All
JsonSchemaLoaderconstructors declarethrows URISyntaxException, IOException. Wrap instantiation in a try-catch or add the exceptions to your method signature (see the Error handling section).
Place your config files under src/main/resources/config/<configType>/ in your project.
JsonSchemaLoader scans config/ on the classpath automatically:
my-project/
src/main/resources/
config/
orders/
order_config.json ← configType "orders"
users/
user_config.json ← configType "users"
JsonSchemaLoader loader = new JsonSchemaLoader(); // scans classpath "config/"
JsonTransformer transformer = new JsonTransformer(loader);
String output = transformer.transformJson(inputJson, "orders");If you want to keep your configs under a different classpath directory (e.g. to avoid collisions with other libraries):
src/main/resources/
my-app/configs/
payments/
payment_config.json
JsonSchemaLoader loader = new JsonSchemaLoader("my-app/configs/");
JsonTransformer transformer = new JsonTransformer(loader);
String output = transformer.transformJson(inputJson, "payments");Useful when configs are managed outside the JAR — for example in a Docker volume, Kubernetes ConfigMap mount, or a shared config-management directory:
Path configDir = Paths.get("/etc/my-app/configs");
// or: Path configDir = Paths.get(System.getenv("CONFIG_DIR"));
JsonSchemaLoader loader = new JsonSchemaLoader(configDir);
JsonTransformer transformer = new JsonTransformer(loader);
String output = transformer.transformJson(inputJson, "orders");The directory must contain immediate subdirectories whose names become the config-type strings:
/etc/my-app/configs/
orders/
order_config.json ← configType "orders"
users/
user_config.json ← configType "users"
{
"parser_configs": [ ... ]
}Each entry in parser_configs maps to one field in the output JSON.
{
"field_name": "outputFieldName",
"is_mandatory": true,
"target_schema": { ... }
}| Field | Type | Description |
|---|---|---|
field_name |
string | Name of the output field |
is_mandatory |
boolean | Throw MissingFieldsException if no value can be resolved |
target_schema |
object | How to extract and transform the value |
Extracts a scalar value. Multiple json_paths are concatenated with a separator (default " "). You can customize the separator using sys_args:
{
"field_name": "fullName",
"target_schema": {
"type": "string",
"mappings": [
{
"json_paths": ["$.firstName", "$.lastName"],
"sys_args": { "separator": ", " }
}
]
}
}Without custom separator (uses default " "):
{
"field_name": "fullName",
"target_schema": {
"type": "string",
"mappings": [
{ "json_paths": ["$.firstName", "$.lastName"] }
]
}
}Extracts multiple values and optionally organises them into objects using target_field.
{
"field_name": "suggestions",
"target_schema": {
"type": "array",
"mappings": [
{ "target_field": "label", "json_paths": ["$.items[*].label"] },
{ "target_field": "value", "json_paths": ["$.items[*].value"] }
]
}
}Output: [{"label":"Option A","value":"val-a"}, ...]
Builds a nested object. Each mapping contributes a field to the result object via target_field.
{
"field_name": "metadata",
"target_schema": {
"type": "object",
"mappings": [
{ "target_field": "source", "json_paths": ["$.meta.src"] },
{ "target_field": "version", "json_paths": ["$.meta.ver"] }
]
}
}Reads true / false (as boolean or string).
{
"field_name": "isActive",
"target_schema": {
"type": "boolean",
"default_value": "false",
"mappings": [{ "json_paths": ["$.active"] }]
}
}Reads an integer (also handles numeric strings like "404").
{
"field_name": "statusCode",
"target_schema": {
"type": "integer",
"mappings": [{ "json_paths": ["$.status"] }]
}
}When the primary path is absent, fallbacks are tried in order.
{
"field_name": "eventId",
"target_schema": {
"type": "string",
"mappings": [
{
"json_paths": ["$.eventId"],
"fallbacks": [
{ "json_paths": ["$.id"] },
{ "json_paths": ["$.transactionId"] }
]
}
]
}
}Returned when all paths and fallbacks resolve to null.
{
"field_name": "messageType",
"target_schema": {
"type": "string",
"default_value": "text",
"mappings": [{ "json_paths": ["$.messageType"] }]
}
}For object type, default_value must be a JSON string:
"default_value": "{\"source\":\"unknown\",\"version\":\"v0\"}"Reference a custom transformer class by its fully-qualified name in transformation_class.
{
"field_name": "normalizedCurrency",
"target_schema": {
"type": "string",
"mappings": [
{
"json_paths": ["$.currencyCode"],
"transformation_class": "com.intuit.json2jsontransformer.transform.UpperCaseTransformer"
}
]
}
}Extra parameters can be passed via sys_args:
{
"json_paths": ["$.firstName", "$.lastName"],
"sys_args": { "separator": ", " }
}Use dot notation in target_field to write into nested objects.
{
"target_field": "address.city",
"json_paths": ["$.location.city"]
}Output: {"address": {"city": "London"}}
Implement the Transformation interface:
public interface Transformation {
Object transformWithPaths(DocumentContext context, List<String> jsonPaths, Map<String, Object> sysArgs);
}package com.example.transform;
import com.jayway.jsonpath.DocumentContext;
import com.jayway.jsonpath.PathNotFoundException;
import com.intuit.json2jsontransformer.transform.Transformation;
import java.util.List;
import java.util.Map;
public class UpperCaseTransformer implements Transformation {
@Override
public Object transformWithPaths(DocumentContext context, List<String> jsonPaths, Map<String, Object> sysArgs) {
for (String path : jsonPaths) {
try {
Object value = context.read(path);
if (value != null) {
return value.toString().toUpperCase();
}
} catch (PathNotFoundException ignored) {}
}
return null;
}
}Reference it in the config:
{
"json_paths": ["$.currencyCode"],
"transformation_class": "com.example.transform.UpperCaseTransformer"
}Tip: Transformers are instantiated once via their no-arg constructor and cached. Keep them stateless.
A common real-world need: two external services send events in completely different JSON shapes, but you want a single normalised output schema.
config/
provider-alpha/
event_config.json
provider-beta/
event_config.json
{
"id": "txn-alpha-999",
"event": "transaction.completed",
"payload": {
"customerId": "cust-alpha-111",
"amount": 4999,
"currencyCode": "usd",
"meta": { "orderId": "order-alpha-777" }
}
}{
"eventType": "payment.success",
"data": {
"transactionId": "txn-beta-888",
"buyer": { "id": "cust-beta-222" },
"total": { "value": 2599, "currency": "eur" },
"externalRef": "order-beta-555"
}
}{
"eventId": "txn-alpha-999",
"eventType": "transaction.completed",
"customerId": "cust-alpha-111",
"amount": 4999,
"currency": "USD",
"orderId": "order-alpha-777"
}{
"parser_configs": [
{ "field_name": "eventId", "is_mandatory": true, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.id"] }] } },
{ "field_name": "eventType", "is_mandatory": true, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.event"] }] } },
{ "field_name": "customerId", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.payload.customerId"] }] } },
{ "field_name": "amount", "is_mandatory": false, "target_schema": { "type": "integer", "mappings": [{ "json_paths": ["$.payload.amount"] }] } },
{ "field_name": "currency", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.payload.currencyCode"], "transformation_class": "com.intuit.json2jsontransformer.transform.UpperCaseTransformer" }] } },
{ "field_name": "orderId", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.payload.meta.orderId"] }] } }
]
}{
"parser_configs": [
{ "field_name": "eventId", "is_mandatory": true, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.data.transactionId"] }] } },
{ "field_name": "eventType", "is_mandatory": true, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.eventType"] }] } },
{ "field_name": "customerId", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.data.buyer.id"] }] } },
{ "field_name": "amount", "is_mandatory": false, "target_schema": { "type": "integer", "mappings": [{ "json_paths": ["$.data.total.value"] }] } },
{ "field_name": "currency", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.data.total.currency"], "transformation_class": "com.intuit.json2jsontransformer.transform.UpperCaseTransformer" }] } },
{ "field_name": "orderId", "is_mandatory": false, "target_schema": { "type": "string", "mappings": [{ "json_paths": ["$.data.externalRef"] }] } }
]
}// JsonSchemaLoader throws URISyntaxException, IOException — propagate or catch
JsonTransformer transformer = new JsonTransformer(new JsonSchemaLoader());
// Route each event to its config type
String configType = determineProvider(incomingEvent); // "provider-alpha" or "provider-beta"
String normalised = transformer.transformJson(incomingEvent, configType);The library throws several exceptions that you should handle in your code:
Thrown when one or more fields marked is_mandatory: true in the config cannot be resolved from the input JSON.
try {
String output = transformer.transformJson(inputJson, "default");
} catch (MissingFieldsException e) {
System.err.println("Missing mandatory fields: " + e.getFields());
// e.getFields() → formatted String, e.g. "[ eventId, sessionId ]"
// e.getFieldList() → List<String> of individual field names for programmatic use
}Note: All fields are evaluated before the exception is thrown. A single call reports every missing mandatory field at once — not just the first one encountered.
Thrown when the input JSON string is malformed or cannot be parsed.
try {
String output = transformer.transformJson(inputJson, "default");
} catch (IOException e) {
System.err.println("Invalid JSON input: " + e.getMessage());
}Thrown when the specified configType doesn't exist or wasn't loaded.
try {
String output = transformer.transformJson(inputJson, "nonexistent-config");
} catch (IllegalArgumentException e) {
System.err.println("Config type not found: " + e.getMessage());
// Message includes available config types
}Thrown during initialization when config files cannot be loaded:
try {
JsonSchemaLoader loader = new JsonSchemaLoader();
JsonTransformer transformer = new JsonTransformer(loader);
} catch (URISyntaxException | IOException e) {
System.err.println("Failed to load configs: " + e.getMessage());
}public String transformSafely(String inputJson, String configType) {
try {
JsonSchemaLoader loader = new JsonSchemaLoader();
JsonTransformer transformer = new JsonTransformer(loader);
return transformer.transformJson(inputJson, configType);
} catch (MissingFieldsException e) {
log.error("Missing mandatory fields: {}", e.getFields());
throw new BusinessException("Required fields missing", e);
} catch (IllegalArgumentException e) {
log.error("Invalid config type: {}", configType);
throw new BusinessException("Config type not found", e);
} catch (IOException e) {
log.error("Invalid JSON input", e);
throw new BusinessException("Malformed JSON", e);
} catch (URISyntaxException e) {
log.error("Failed to initialize transformer", e);
throw new BusinessException("Initialization failed", e);
}
}JsonTransformer and JsonSchemaLoader are thread-safe for concurrent read operations:
- ✅ Safe: Multiple threads can call
transformJson()concurrently with the sameJsonTransformerinstance - ✅ Safe: Configs are loaded once at initialization and cached
- ✅ Safe: Custom transformers are instantiated once and cached (must be stateless)
Important: Custom Transformation implementations must be stateless since they are cached and reused across threads. Do not store instance variables that could cause race conditions.
Problem: JsonSchemaLoader warns "No config directory found on classpath"
Solutions:
- Ensure config files are in
src/main/resources/config/<configType>/ - Verify files are included in the JAR (check
target/classes/after build) - Use explicit path:
new JsonSchemaLoader("my-custom-path/") - Use file-system path:
new JsonSchemaLoader(Paths.get("/path/to/configs"))
Problem: IllegalArgumentException: No config loaded for configType='xyz'
Solutions:
- Check the directory name matches exactly (case-sensitive)
- Verify at least one
.jsonfile exists in that directory - List available configs:
transformer.getConfigsMap().keySet()
Problem: Transformer class cannot be instantiated
Solutions:
- Ensure the class implements
Transformationinterface - Verify fully-qualified class name in config matches exactly
- Check the class is on the classpath
- Ensure the class has a no-arg constructor
- Check logs for specific error messages
Problem: Fields are not being extracted
Solutions:
- Test JSONPath expressions using JSONPath Evaluator
- Verify the input JSON structure matches your paths
- Check for typos in field names (case-sensitive)
- Use fallback paths for optional fields
- Enable debug logging to see what's being extracted
Problem: IOException when parsing input
Solutions:
- Validate input JSON using a JSON validator
- Check for unescaped control characters (library allows them, but verify)
- Ensure proper encoding (UTF-8)
Problem: com.jayway.jsonpath.internal.path.CompiledPath emits a DEBUG line for every path evaluation, flooding application logs.
Solution: Suppress it in your logback.xml (or equivalent):
<logger name="com.jayway.jsonpath" level="WARN"/>The project supports building two types of JAR files:
The regular JAR contains only the library classes and requires clients to add transitive dependencies.
Build command:
mvn clean packageThis creates: target/json2jsontransformer-1.0.0.jar
When to use:
- Maven/Gradle projects that can manage dependencies
- When you want to control dependency versions
- Smaller JAR size (~33KB)
Required dependencies (if using regular JAR):
com.fasterxml.jackson.core:jackson-databind:2.17.2com.jayway.jsonpath:json-path:2.9.0net.minidev:json-smart:2.5.2org.apache.commons:commons-lang3:3.14.0commons-collections:commons-collections:3.2.2org.slf4j:slf4j-api:2.0.13
The fat JAR includes all transitive dependencies bundled, so clients don't need to add them separately.
Build command:
mvn clean packageThis creates both:
target/json2jsontransformer-1.0.0.jar(regular JAR)target/json2jsontransformer-1.0.0-fat.jar(fat JAR with all dependencies)
When to use:
- Standalone applications
- Non-Maven/Gradle projects
- Simple deployment scenarios
- When you want a single JAR file
Note: The fat JAR is larger (~4MB) but includes everything needed to run. The regular JAR is only ~33KB but requires clients to add transitive dependencies.
Skip tests:
mvn clean package -DskipTestsBuild only regular JAR (skip fat JAR):
mvn clean package -Dmaven.shade.skip=truemvn testTests cover all five schema types (string, array, object, boolean, integer), fallback paths, default values, mandatory field enforcement, custom transformers, and multi-config routing.
Contributions are welcome! Please follow these guidelines:
- Fork the repository and create a feature branch
- Write tests for new functionality
- Follow existing code style and conventions
- Update documentation as needed
- Submit a pull request with a clear description
# Clone the repository
git clone https://github.com/intuit/json2jsontransformer.git
cd json2jsontransformer
# Build and run tests
mvn clean test
# Build JARs
mvn clean packagePlease report bugs or request features by opening an issue on GitHub. Include:
- Library version
- Java version
- Steps to reproduce
- Expected vs actual behavior
- Relevant code/config examples
This project is licensed under the Apache License 2.0.
- Repository: GitHub
- Issues: GitHub Issues
- JSONPath Documentation: JSONPath