A Java library for parsing, validating, navigating, and manipulating JSON data. Jsonic provides a unified tree-node architecture with type-safe accessors, path-based navigation, declarative schema validation, and seamless object-relational mapping.
- Unified recursive tree architecture: Each
JSONObjectis a standalone node holding a key-value pair. Nodes model scalars, primitive arrays, or nested object hierarchies, forming a recursive tree that mirrors any JSON structure. - Constructor-based parsing: Parse JSON files or raw strings directly during
JSONObjectinstantiation. No separate parser step required. - Granular type-safe accessors: Retrieve typed values using optional lookups (
getString(),getInt()) or strict requirements (requireString(),requireInt()) that throw descriptive exceptions on failure. - Path-based navigation: Resolve deeply nested values using dot-separated string paths or vararg segments (
findStringAt("user.profile/city")), with escaped-dot support for keys that contain dots. - Prioritized subtree searching: Search across multiple fallback keys in priority order (
findString(List.of("email", "phone", "username"))), returning the first match found anywhere in the subtree. - Deep structural inspection: Query node type with
isNull(),isArray(),isObject(),isScalar(), andsize(), or check key presence withhasKey(),hasAllKeys(), andhasAnyKey(). - Declarative schema validation: Enforce structural rules inline: required keys, allowed-key whitelists, and isolated structure validation blocks with
requireStructure(). - Custom exception suppliers: Inject your own exception factories into any
require*call for integration with application-specific error handling. - Default value suppliers: Provide
Supplier<T>fallbacks to anyfind*call for clean, null-free value resolution. - Mutation API: Update existing keys with
put(), remove keys withremove(), and chain node construction withmerge(). - Native Java conversion: Export tree structures to standard
Map<String, Object>orList<Object>withtoMap()andtoList(). - Object mapping via
JSONSerializable: ImplementJSONSerializable<T>on your model classes to enable reflection-based serialization and custom deserialization. - Functional deserialization: Convert any node to a typed object using
toObject(Function<JSONObject, T>)without requiring an interface. - Structural equality and copying: Compare nodes structurally with
equals()and deep-clone entire subtrees withclone(). - Pretty printing: Produce human-readable, multi-line, indented JSON serialization with
toString(), suitable for logging and file output. - Full Unicode support: Strings support the complete Unicode range, including all JSON escape sequences and surrogate pairs.
- Java 17 or higher
- Maven 3.x or Gradle 7.x
<dependency>
<groupId>io.github.stevenlagoy</groupId>
<artifactId>jsonic</artifactId>
<version>2.0.0</version>
</dependency>dependencies {
implementation 'io.github.stevenlagoy:jsonic:2.0.0'
}dependencies {
implementation("io.github.stevenlagoy:jsonic:2.0.0")
}Parse from a local JSON file:
Path jsonPath = Path.of("path", "to", "file.json");
JSONObject json = new JSONObject(jsonPath);Parse from a raw string:
String rawJson = "{\"title\": \"Jsonic\",\"version\": 2}";
JSONObject json = new JSONObject("root", List.of(rawJson));Parse from multiple lines (e.g. from a file reader):
List<?> lines = Files.readAllLines(Path.of("data.json"));
JSONObject json = new JSONObject("data", lines);In Kotlin:
val jsonFromFile = JSONObject(Path.of("config", "settings.json"))
val jsonFromString = JSONObject("root", listOf("{\"title\": \"Jsonic\"}"))Access the value of the current node directly, without searching the tree:
JSONObject node = new JSONObject("score", 99);
Integer score = node.getInt(); // 99
String asString = node.getString(); // null -- value is not a stringUse require*() to assert the type and throw if it doesn't match:
int score = node.requireInt(); // 99
String s = node.requireString(); // throws IllegalArgumentExceptionProvide a custom exception for integration with your error handling:
int level = node.requireInt(() -> new IllegalArgumentException("'level' field must be a number"));find*() methods search depth-first across the entire subtree rooted at the current node and return Optional results. require*() variants throw if nothing is found.
Note: find*() searches the entire subtree, not just the immediate children of the current node. If a kye appears at multiple levels of nesting, the first depth-first match is returned. Use path-based navigation (section 4) to target a specific level.
JSONObject json = new JSONObject(Path.of("player.json"));
// Optional lookup -- safe to call on any node
Optional<String> name = json.findString("name");
Optional<Integer> level = json.findInt("level");
// Lookup with a default value supplier
String displayName = json.findString("displayName", () -> "Anonymous");
double rating = json.findDouble("rating", () -> 0.0);
int maxRetries = json.findInt("maxRetries", () -> 3);
// Strict lookup -- throws with a descriptive message if not found
String id = json.requireString("id");
int health = json.requireInt("health");
// Strict lookup with a custom exception
String token = json.requireString("authToken", () -> new IllegalArgumentException("Authentication token is required"));Search across multiple fallback keys in priority order:
// Returns the value of the first key found: "email", then "phone", then "username"
Optional<String> contact = json.findString(List.of("email", "phone", "username"));
String contactRequired = json.requireString(List.of("email", "phone", "username"));
// With a default supplier
String contactOrDefault = json.findString(List.of("email", "phone", "username"), () -> "no-contact-provided");In Kotlin, Optional interops cleanly with orElse and orElseGet, and the supplier-based overload avoid Optional entirely:
val name = json.findString("name").orElse("Unknown")
val contact = json.findString(listOf("email", "phone")).orElse(null)
// With a default supplier
val display = json.findString("displayName") { "Anonymous" }
val retries = json.findInt("maxRetries") { 3 }Navigate nested structures using dot-separated paths or vararg segments:
JSONObject json = new JSONObject(Path.of("organization.json"));
// Dot-separated path
Optional<String> city = json.findStringAt("address.city");
double lat = json.requireDoubleAt("location.coordinates.lat");
// Varargs path -- equivalent to the above
Optional<String> city2 = json.findStringAt("address", "city");
// Keys that contain dots can be escaped with a backslash
Optional<String> temp = json.findStringAt("dates.2024\\.01\\.15.temperature");
// Navigate to a node rather than its value
Optional<JSONObject> addressNode = json.findNodeAt("address");
JSONObject required = json.requireNodeAt("address");In Kotlin:
val city = json.findStringAt("address.city").orElse("Unknown")
val lat = json.requireDoubleAt("location.coordinates.lat")JSONObject node = new JSONObject(Path.of("response.json"));
node.isNull(); // true if value is null
node.isScalar(); // true if value is a String, Number, or Boolean
node.isArray(); // true if value is a list of primitives
node.isObject(); // true if value is a list of JSONObjects
node.size(); // number of elements (0 for null, 1 for scalar, n for list)
// Key presence -- correctly returns true even for null-valued keys
boolean hasId = node.hasKey("id");
boolean hasAny = node.hasAnyKey("email", "phone");
boolean hasAll = node.hasAllKeys("id", "name", "version");
// Immediate child keys of this node
List<?> keys = node.getFieldKeys();Validate structure inline before processing:
JSONObject payload = new JSONObject(Path.of("request.json"));
// Assert required keys exist (throws IllegalArgumentException if missing)
payload.requireKey("id");
payload.requireAllKeys("title", "version", "organization");
payload.requireAnyKey("apiKey", "auth_token");
// Reject unexpected keys
payload.requireAllowedKeys(List.of("id", "title", "version", "organization"));
// Validate a nested object in isolation
payload.requireStructure("organization", org -> {
org.requireKey("name");
org.requireKey("address");
org.requireAllowedKeys(List.of("name", "address", "contactEmail"));
});With custom exceptions:
payload.requireKey("id", () -> new IllegalStateException("Request payload must include 'id'"));
payload.requireAllKeys(
List.of("title", "version"),
() -> new IllegalStateException("Payload is missing required metadata fields"));JSONSerializable<T> is an interface that adds standardized JSON serialization and deserialization to your model classes. Implementing it requires two methods:
toJson(): Converts this object into aJSONObjecttree. The static helperJSONSerializable.toJson(Object)uses reflection to automatically map all public non-static fields, which is sufficient for simple models. For more control, build theJSONObjectmanually.fromJson(JSONObject): Reads field values from aJSONObject, applies them tothis, and returnsthis. This pattern allows the same instance to be reused as a deserialization target, and enables the JSON constructor pattern described below.
A common and recommended pattern is to add a constructor that accepts a JSONObject and deserializes itself immediately:
public MyClass(JSONObject json) {
this(); // Call no-arg (or another) constructor to set defaults
this.fromJson(json); // Deserialize
}This allows clean deserialization at the call site: new MyClass(json).
public class Tag implements JSONSerializable<Tag> {
public String name;
public String color;
public Tag() {}
public Tag(JSONObject json) {
this();
this.fromJson(json);
}
@Override
public JSONObject toJson() {
// Reflects all public non-static fields automatically
return JSONSerializable.toJson(this);
}
@Override
public Tag fromJson(JSONObject json) {
this.name = json.requireString("name");
this.color = json.findString("color", () -> "#000000");
return this;
}
}Usage:
Tag tag = new Tag(json); // JSON constructor
Tag tag = new Tag().fromJson(json); // Equivalent explicit formWhen your class has private fields, computed properties, or fields you want
to exclude, build the JSONObject manually instead of using
JSONSerializable.toJson(this):
public class Article implements JSONSerializable<Article> {
public String title;
public int wordCount;
public boolean published;
public Tag primaryTag; // nested JSONSerializable
public List<Tag> relatedTags; // collection of JSONSerializable
public Article() {}
public Article(JSONObject json) {
this();
this.fromJson(json);
}
@Override
public JSONObject toJson() {
JSONObject json = new JSONObject(getClass().getSimpleName());
json.put("title", title);
json.put("wordCount", wordCount);
json.put("published", published);
// Nested object -- serialized recursively
if (primaryTag != null) {
json.put("primaryTag", primaryTag.toJson());
}
// Collection -- convert each element
if (relatedTags != null) {
json.put("relatedTags", JSONSerializable.collectionToJson(relatedTags));
}
return json;
}
@Override
public Article fromJson(JSONObject json) {
this.title = json.requireString("title");
this.wordCount = json.findInt("wordCount").orElse(0);
this.published = json.findBoolean("published").orElse(false);
// Deserialize nested object
json.findJson("primaryTag")
.ifPresent(tagJson -> this.primaryTag = new Tag().fromJson(tagJson));
// Deserialize collection
this.relatedTags = new ArrayList<>();
json.findArray("relatedTags").ifPresent(arr -> {
for (Object item : arr) {
if (item instanceof JSONObject tagJson) {
this.relatedTags.add(new Tag(tagJson));
}
}
});
return this;
}
}Only the parent class may implement JSONSerializable<T>. Subclasses extend the parent's serialization by calling super.toJson() and super.fromJson(), then handling their own additional fields.
public class FeaturedArticle extends Article {
public String featuredImageUrl;
public int featuredRank;
public FeaturedArticle() {}
public FeaturedArticle(JSONObject json) {
this();
this.fromJson(json);
}
@Override
public JSONObject toJson() {
// Start with the parent's serialization, then add subclass fields
JSONObject json = super.toJson();
json.put("featuredImageUrl", featuredImageUrl);
json.put("featuredRank", featuredRank);
return json;
}
@Override
public FeaturedArticle fromJson(JSONObject json) {
// Apply parent fields first, then subclass fields
super.fromJson(json);
this.featuredImageUrl = json.findString("featuredImageUrl", () -> null);
this.featuredRank = json.findInt("featuredRank", () -> 0);
return this;
}
}This pattern naturally extends to depper hierarchies: each level calls its parent's methods and handles only its own fields.
Kotlin's val properties cannot be assigned after construction, so fields that will be populated by fromJson() should be declared as var with internal set to allow mutation from within the class while keeping them read-only externally:
class Tag(
name: String = "",
color: String = "#000000",
) : JSONSerializable<Tag> {
var name: String = name
internal set
var color: String = color
internal set
constructor(json: JSONObject) : this() {
fromJson(json)
}
override fun toJson() = JSONSerializable.toJson(this)
override fun fromJson(json: JSONObject) = this.apply {
name = json.requireString("name")
color = json.findString("color") { "#000000" }
}
}Usage:
val tag = Tag(json) // JSON constructor
val tag = Tag().fromJson(json) // Equivalent explicit formWhen your class has private fields, computed properties, or fields you want to exclude, build the JSONObject manually instead of using JSONSerializable.toJson(this):
class Product(
name: String = "",
var price: Double = 0.0,
var inStock: Boolean = true,
) : JSONSerializable<Product> {
var name: String = name
internal set
constructor(json: JSONObject) : this() {
fromJson(json)
}
override fun toJson() = JSONObject(
requireNotNull(this::class.simpleName), // Anonymous classes cannot be serialized; remember that keys in a JSON layer must be unique
listOf(
JSONObject("name", name),
JSONObject("price", price),
JSONObject("inStock", inStock),
)
)
override fun fromJson(json: JSONObject) = apply {
name = json.requireString("name")
price = json.findDouble("price") { 0.0.also { println("Could not find price for $name") } } // Scope functions for additional logging
inStock = json.findBoolean("inStock") { true }
}
}The parent class implements JSONSerializable<T> directly. Its toJson and fromJson handle only its own fields:
open class Article(
title: String = "",
wordCount: Int = 0,
var published: Boolean = false,
) : JSONSerializable<Article> {
var title: String = title
internal set
var wordCount: Int = wordCount
internal set
constructor(json: JSONObject) : this() {
fromJson(json)
}
override fun toJson() = JSONObject(
title,
listOf(
JSONObject("title", title),
JSONObject("wordCount", wordCount),
JSONObject("published", published),
)
)
override fun fromJson(json: JSONObject) = apply {
title = json.requireString("title")
wordCount = json.findInt("wordCount") { 0.also { println("Could not find 'wordCount' for $title") } } // Scope functions for additional logging
published = json.findBoolean("published") { false }
}
}
class FeaturedArticle(
title: String = "",
wordCount: Int = 0,
published: Boolean = false,
featuredImageUrl: String = "",
var featuredRank: Int = 0,
) : Article(title, wordCount, published) {
var featuredImageUrl: String = featuredImageUrl
internal set
constructor(json: JSONObject) : this() {
fromJson(json)
}
// super.toJson() builds the parent's node, then merge() appends child fields
override fun toJson() = super.toJson().merge(
JSONObject("featuredImageUrl", featuredImageUrl),
JSONObject("featuredRank", featuredRank),
)
// super.fromJson() populates parent fields first, apply continues with child fields
override fun fromJson(json: JSONObject) = apply {
super.fromJson(json)
featuredImageUrl = json.findString("featuredImageUrl") { "" }
featuredRank = json.findInt("featuredRank") { 0.also { println("Could not find 'featuredRank' for $title, assigning lowest priority") } }
}
}class Campaign(
name: String = "",
primaryTag: Tag = Tag(),
relatedTags: List = emptyList(),
) : JSONSerializable {
var name: String = name
internal set
var primaryTag: Tag = primaryTag
internal set
var relatedTags: List = relatedTags
internal set
constructor(json: JSONObject) : this() {
fromJson(json)
}
override fun toJson() = JSONObject(
name,
listOf(
JSONObject("name", name),
JSONObject("primaryTag", primaryTag.toJson()),
// collectionToJson calls toJson() on each JSONSerializable element
// Consider adding an extension function `Collection.toJson()` in your project
JSONObject("relatedTags", JSONSerializable.collectionToJson(relatedTags)),
)
)
override fun fromJson(json: JSONObject) = apply {
name = json.requireString("name")
primaryTag = json.findJson("primaryTag")
.map { Tag(it) }
.orElseGet { Tag() }
relatedTags = json.findArray("relatedTags")
.map { arr -> arr.filterIsInstance().map { Tag(it) } }
.orElseGet { emptyList() }
}
}Article article = new Article();
article.title = "Getting Started with Jsonic";
article.wordCount = 1200;
article.published = true;
article.primaryTag = new Tag();
article.primaryTag.name = "java";
article.primaryTag.color = "#E76F00";
// Serialize to JSON tree
JSONObject json = article.toJson();
System.out.println(json);
// Deserialize from JSON tree
Article loaded = new Article().fromJson(json);
System.out.println(loaded.title); // "Getting Started with Jsonic"If your class does not implement JSONSerializable<T>, toObject() applies a function to the JSONObject and returns the result. This is useful for one-off conversion or for integrating with classes you don't control:
JSONObject json = new JSONObject(Path.of("user.json"));
// Method reference: calls a static or instance method that accepts a JSONObject
User user = json.toObject(User::fromJson);
// Lambda: any transformation that produces a value from a JSONObject
String label = json.toObject(j -> j.requireString("label"));
int count = json.toObject(j -> j.findInt("count", () -> 0));In Kotlin:
val user = json.toObject(User::fromJson)
val label = json.toObject { it.requireString("label") }
val count = json.toObject { it.findInt("count" { 0 }) }JSONObject response = new JSONObject("response");
// put() initializes the node as an object container if needed,
// and replaces an existing key's value if one is already present.
// Returns the previous value, or null if the key was not present.
response.put("status", "success");
response.put("code", 200);
response.put("timestamp", System.currentTimeMillis());
// Remove a key: returns true if the key was found and removed
response.remove("timestamp");
// Merge child nodes: adds fields from another JSONObject into this one
JSONObject meta = new JSONObject("meta");
meta.put("version", "2.0.0");
meta.put("author", "stevenlagoy");
response.merge(meta);
System.out.println(response);
// {
// "status" : "success",
// "code" : 200,
// "meta" : {
// "version" : "2.0.0",
// "author" : "stevenlagoy"
// }
// }toMap() and toList() convert the JSONObject tree into standard Java collections, which is useful for interoperability with other libraries or frameworks which expect plan Map and List types rather than JSONObject:
JSONObject json = new JSONObject(Path.of("config.json"));
// Export the entire tree as a nested Map
// Nested objects become nested Maps; arrays become Lists
Map<String, Object> map = json.toMap();
// Export a specific array node as a List
List<Object> list = json.findNodeAt("tags")
.map(JSONObject::toList)
.orElse(Collections.emptyList());Version 2.0.0 is a full rewrite. The following changes affect all v1.x users:
| v1.x | v2.0.0 |
|---|---|
JSONProcessor.processJson(path) |
new JSONObject(path) |
JSONProcessor.processValue(str) |
new JSONObject("key", List.of(str)) |
json.getValue("key") |
json.find("key") |
json.getString("key", default) |
json.findString("key", () -> default) |
Jsonic<T> interface |
JSONSerializable<T> interface |
git clone https://github.com/stevenlagoy/Jsonic.git
cd json-java-objectifier
mvn clean installOr with Gradle:
./gradlew buildContributions are welcome. Before opening a pull request, please check the open issues to see if the problem or feature is already being tracked.
If you find a bug or have a feature request, open an issue on GitHub:
- Go to the Issues tab
- Click New Issue and choose the appropriate template (Bug Report or Feature Request)
- Include as much detail as possible: your Java version, a minimal reproduction case for bugs, and the expected vs. actual behavior
- Label the issue appropriately if you have permission to do so
- Fork the repository and create your branch from
main:
git checkout -b feature/my-feature
# or
git checkout -b fix/issue-123- Make your changes. For bug fixes, add a test that would have caught the bug. For new features, add tests covering the new behavior.
- Ensure all existing tests pass:
mvn test
# or
./gradlew test- Commit your changes with a clear message:
git commit -m "Fix: correct null handling in findInt for missing keys"
git commit -m "Feature: add findNodeAt path navigation"- Push your branch and open a Pull Request against
main - In the Pull Request description, reference any related issues (e.g.
Closes #42) and summarize what changed and why
- Follow the existing formatting conventions in the codebase
- All public methods must have Javadoc
- Null safety annotations (
@NotNull,@Nullable) are required on all method parameters and return types - Keep changes focused: one concern per pull request
This project is licensed under the Apache 2.0 License -- see LICENSE.md for details.
- Built with Java 17
- JUnit for testing
- Maven for build management