Skip to content

Here's a refactoring of the JSON Schema code to enhance clarity and maintainability. #450

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

13 changes: 13 additions & 0 deletions src/main/kotlin/wu/seal/jsontokotlin/model/ConfigManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@ package wu.seal.jsontokotlin.model

import com.intellij.ide.util.PropertiesComponent
import wu.seal.jsontokotlin.test.TestConfig
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalTime
import java.time.OffsetDateTime

/**
* Config Manager
* Created by Seal.Wu on 2018/2/7.
*/
object ConfigManager : IConfigManager {

//https://json-schema.org/understanding-json-schema/reference/string.html#format
val JSON_SCHEMA_FORMAT_MAPPINGS = mapOf(
"date-time" to OffsetDateTime::class.java.canonicalName,
"date" to LocalDate::class.java.canonicalName,
"time" to LocalTime::class.java.canonicalName,
"decimal" to BigDecimal::class.java.canonicalName
//here can be another formats
)

private const val INDENT_KEY = "json-to-kotlin-class-indent-space-number"
private const val ENABLE_MAP_TYP_KEY = "json-to-kotlin-class-enable-map-type"
private const val ENABLE_MINIMAL_ANNOTATION = "json-to-kotlin-class-enable-minimal-annotation"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,46 +2,121 @@ package wu.seal.jsontokotlin.model.jsonschema

import com.google.gson.annotations.SerializedName

/**
* Represents a base JSON object definition in a JSON Schema.
* This class includes common properties found in JSON Schema objects, such as `id`, `\$ref`,
* `title`, `description`, `type`, and validation keywords like `properties`, `oneOf`, etc.
*
* See JSON Schema Specification: [https://json-schema.org/understanding-json-schema/](https://json-schema.org/understanding-json-schema/)
*/
open class JsonObjectDef(
//See: https://json-schema.org/understanding-json-schema/structuring.html
/**
* The `\$id` keyword defines a URI for the schema, and the base URI that other URI references within the schema are resolved against.
* See: [https://json-schema.org/understanding-json-schema/structuring.html#the-id-property](https://json-schema.org/understanding-json-schema/structuring.html#the-id-property)
*/
@SerializedName("\$id")
val id: String? = null,

/**
* The `\$ref` keyword is used to reference another schema.
* This allows for reusing parts of schemas or creating complex recursive schemas.
* See: [https://json-schema.org/understanding-json-schema/structuring.html#ref](https://json-schema.org/understanding-json-schema/structuring.html#ref)
*/
@SerializedName("\$ref")
val ref: String? = null,

/**
* The `title` keyword provides a short, human-readable summary of the schema's purpose.
*/
val title: String? = null,

/**
* The `description` keyword provides a more detailed explanation of the schema's purpose.
*/
val description: String? = null,

/** type may contains a string or an array of string (ArrayList),
* where usually the first entry is "null" (property isTypeNullable)
* and the second entry is the type string (property typeString)
* */
protected val type: Any? = null,
/**
* The `type` keyword defines the data type for a schema.
* It can be a string (e.g., "object", "string", "number") or a list of strings (e.g., ["string", "null"]).
* This property stores the raw value from JSON, which can be a String or a List.
* Use [typeString] to get the actual type name and [isTypeNullable] to check for nullability.
* See: [https://json-schema.org/understanding-json-schema/reference/type.html](https://json-schema.org/understanding-json-schema/reference/type.html)
*/
protected val type: Any? = null, /* String or List<String> */

/**
* The `properties` keyword defines a map of property names to their schema definitions for an object type.
* See: [https://json-schema.org/understanding-json-schema/reference/object.html#properties](https://json-schema.org/understanding-json-schema/reference/object.html#properties)
*/
val properties: Map<String, PropertyDef>? = null,
val additionalProperties: Any? = null,

/**
* The `additionalProperties` keyword controls whether additional properties are allowed in an object,
* and can also define a schema for those additional properties.
* It can be a boolean (true/false) or a schema object.
* See: [https://json-schema.org/understanding-json-schema/reference/object.html#additionalproperties](https://json-schema.org/understanding-json-schema/reference/object.html#additionalproperties)
*/
val additionalProperties: Any? = null, // Boolean or PropertyDef

/**
* The `required` keyword specifies an array of property names that must be present in an object.
* See: [https://json-schema.org/understanding-json-schema/reference/object.html#required](https://json-schema.org/understanding-json-schema/reference/object.html#required)
*/
val required: Array<String>? = null,

/** See: https://json-schema.org/understanding-json-schema/reference/combining.html */
/**
* The `oneOf` keyword specifies that an instance must be valid against exactly one of the subschemas in the array.
* See: [https://json-schema.org/understanding-json-schema/reference/combining.html#oneof](https://json-schema.org/understanding-json-schema/reference/combining.html#oneof)
*/
val oneOf: Array<PropertyDef>? = null,

/**
* The `allOf` keyword specifies that an instance must be valid against all of the subschemas in the array.
* See: [https://json-schema.org/understanding-json-schema/reference/combining.html#allof](https://json-schema.org/understanding-json-schema/reference/combining.html#allof)
*/
val allOf: Array<PropertyDef>? = null,

/**
* The `anyOf` keyword specifies that an instance must be valid against at least one of the subschemas in the array.
* See: [https://json-schema.org/understanding-json-schema/reference/combining.html#anyof](https://json-schema.org/understanding-json-schema/reference/combining.html#anyof)
*/
val anyOf: Array<PropertyDef>? = null,

/**
* The `not` keyword specifies that an instance must not be valid against the given subschema.
* See: [https://json-schema.org/understanding-json-schema/reference/combining.html#not](https://json-schema.org/understanding-json-schema/reference/combining.html#not)
*/
val not: Array<PropertyDef>? = null,

/**
* Custom extension property `x-abstract`. If true, suggests this schema definition is intended
* as an abstract base and might not be instantiated directly.
*/
@SerializedName("x-abstract")
val x_abstract: Boolean? = null

) {

/** returns correct JsonSchema type as string */
/**
* Gets the primary JSON Schema type as a string.
* If the `type` property is an array (e.g., `["null", "string"]`), this returns the first non-"null" type.
* If `type` is a single string, it returns that string.
* Returns `null` if the type cannot be determined or is explicitly "null" without other types.
*/
val typeString: String?
get() = if (type is ArrayList<*>) type.first { it != "null" } as String else type as? String
get() = if (type is ArrayList<*>) type.firstOrNull { it != "null" } as? String else type as? String
Copy link
Preview

Copilot AI May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a more general List<*> check instead of ArrayList<*> to support other collection implementations.

Suggested change
get() = if (type is ArrayList<*>) type.firstOrNull { it != "null" } as? String else type as? String
get() = if (type is List<*>) type.firstOrNull { it != "null" } as? String else type as? String

Copilot uses AI. Check for mistakes.


/** returns true if the object can be null */
/**
* Checks if the schema definition allows for a "null" type.
* This can be true if:
* - The `type` property is an array containing "null" (or actual `null`).
* - Any subschema under `oneOf` allows for a "null" type.
* - The [typeString] itself is "null" or `null`.
*/
val isTypeNullable: Boolean
get() = when {
type is ArrayList<*> -> type.any { it == null || it == "null" }
oneOf?.any { it.type == null || it.type == "null" } == true -> true
oneOf?.any { it.type == null || (it.type as? String) == "null" || (it.type is ArrayList<*> && it.type.any { subType -> subType == null || subType == "null"}) } == true -> true
else -> typeString == null || typeString == "null"
}

Expand Down
131 changes: 101 additions & 30 deletions src/main/kotlin/wu/seal/jsontokotlin/model/jsonschema/JsonSchema.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,19 @@ package wu.seal.jsontokotlin.model.jsonschema

import com.google.gson.annotations.SerializedName

// See specification: https://json-schema.org/understanding-json-schema/reference/object.html
/**
* Represents a JSON schema document.
*
* This class models the structure of a JSON schema, allowing for parsing and resolving
* references (`$ref`) within the schema. It supports standard schema keywords like
* `definitions`, `$defs`, and `properties`.
*
* See JSON Schema Specification: [https://json-schema.org/understanding-json-schema/](https://json-schema.org/understanding-json-schema/)
*
* @property schema The value of the `\$schema` keyword, indicating the schema dialect.
* @property definitions A map of definitions, primarily used in older JSON schema versions.
* @property defs A map of definitions, introduced in Draft 2019-09 as a replacement for `definitions`.
*/
class JsonSchema(
@SerializedName("\$schema")
val schema: String? = null,
Expand All @@ -14,47 +26,106 @@ class JsonSchema(
// Get a combined map of both definitions and $defs
// This allows backward compatibility with older schema versions
private val allDefinitions: Map<String, PropertyDef>
get() {
val combinedMap = definitions.toMutableMap()
defs?.let { combinedMap.putAll(it) }
return combinedMap
}

//See: https://json-schema.org/understanding-json-schema/structuring.html
get() = definitions + (defs ?: emptyMap())

/**
* Resolves a JSON Pointer reference (`$ref`) to a [PropertyDef] within this schema.
*
* Currently, only local references (starting with `#`) are supported.
* Examples of supported $ref formats:
* - `#/definitions/MyType`
* - `#/\$defs/AnotherType`
* - `#/properties/user/properties/address`
* - `#MyObject` (if `MyObject` is an id of a definition at the root)
*
* See JSON Schema structuring: [https://json-schema.org/understanding-json-schema/structuring.html](https://json-schema.org/understanding-json-schema/structuring.html)
*
* @param ref The JSON Pointer reference string (e.g., `#/definitions/User`).
* @return The resolved [PropertyDef].
* @throws IllegalArgumentException if the `ref` string is malformed (e.g., too short).
* @throws NotImplementedError if the `ref` points to a non-local definition (doesn't start with '#').
* @throws ClassNotFoundException if the definition pointed to by `ref` cannot be found.
*/
fun resolveDefinition(ref: String): PropertyDef {
if (ref.length < 2) throw IllegalArgumentException("Bad ref: $ref")
if (!ref.startsWith("#")) throw NotImplementedError("Not local definitions are not supported (ref: $ref)")

return resolveLocalDefinition(ref)
}

/**
* Resolves a local definition path (starting with '#').
* @param ref The definition reference string.
* @return The resolved [PropertyDef].
* @throws ClassNotFoundException if the definition is not found.
* @throws NotImplementedError if the path structure is not supported.
* @throws IllegalArgumentException if the path contains unknown components.
*/
private fun resolveLocalDefinition(ref: String): PropertyDef {
val path = ref.split('/')
return when {
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0] }
?: throw ClassNotFoundException("Definition $ref not found")
path[1] == "definitions" -> definitions[path[2]]
?: throw ClassNotFoundException("Definition $ref not found")
path[1] == "\$defs" -> defs?.get(path[2])
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0] } // TODO: This could be path[0].substring(1) if # is always present
Copy link
Preview

Copilot AI May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When matching a single-segment ref, strip the leading '#' (e.g., use path[0].removePrefix("#")) so local refs without slashes resolve correctly.

Suggested change
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0] } // TODO: This could be path[0].substring(1) if # is always present
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0].removePrefix("#") }

Copilot uses AI. Check for mistakes.

?: throw ClassNotFoundException("Definition $ref not found")
Comment on lines +67 to 68
Copy link
Preview

Copilot AI May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove or resolve this TODO before merging to avoid leaving outdated comments in production code.

Suggested change
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0] } // TODO: This could be path[0].substring(1) if # is always present
?: throw ClassNotFoundException("Definition $ref not found")
path.count() == 1 -> {
val id = if (path[0].startsWith("#")) path[0].substring(1) else path[0]
allDefinitions.values.firstOrNull { it.id == id }
?: throw ClassNotFoundException("Definition $ref not found")
}

Copilot uses AI. Check for mistakes.

path[1] == "definitions" -> findDefinitionInMaps(path[2], definitions, ref, "definitions")
path[1] == "\$defs" -> findDefinitionInMaps(path[2], defs, ref, "\$defs")
path[1] == "properties" -> {
var property: PropertyDef = properties?.get(path[2])
?: throw ClassNotFoundException("Definition $ref not found")
val iterator = path.subList(3, path.count()).iterator()
do {
val next = iterator.next()
property = when (next) {
"properties" -> {
val propName = iterator.next()
property.properties?.get(propName)
?: throw ClassNotFoundException("Definition $propName not found at path $ref")
}
"items" -> property.items ?: throw ClassNotFoundException("Definition $next not found at path $ref")
else -> throw IllegalArgumentException("Unknown json-object property $next not found at path $ref")
}
} while (iterator.hasNext())

property
val initialProperty = properties?.get(path[2])
?: throw ClassNotFoundException("Definition '${path[2]}' not found in properties at path $ref")
findPropertyRecursive(initialProperty, path.subList(3, path.count()).iterator(), ref)
}
else -> throw NotImplementedError("Cannot resolve ref path: $ref")
}
}

/**
* Finds a definition in the provided map.
* @param defName The name of the definition to find.
* @param map The map to search in (can be nullable, e.g. `defs`).
* @param originalRef The original reference string (for error reporting).
* @param mapName The name of the map being searched (for error reporting).
* @return The resolved [PropertyDef].
* @throws ClassNotFoundException if the definition is not found in the map.
*/
private fun findDefinitionInMaps(
defName: String,
map: Map<String, PropertyDef>?,
originalRef: String,
mapName: String
): PropertyDef {
return map?.get(defName)
?: throw ClassNotFoundException("Definition '$defName' not found in '$mapName' at path $originalRef")
}

/**
* Recursively traverses properties based on the path segments.
* @param currentProperty The current [PropertyDef] being inspected.
* @param pathIterator An iterator for the remaining path segments.
* @param originalRef The original reference string (for error reporting).
* @return The resolved [PropertyDef].
* @throws ClassNotFoundException if a segment in the path is not found.
* @throws IllegalArgumentException if an unknown path segment is encountered.
*/
private fun findPropertyRecursive(
currentProperty: PropertyDef,
pathIterator: Iterator<String>,
originalRef: String
): PropertyDef {
var property = currentProperty
while (pathIterator.hasNext()) {
val segment = pathIterator.next()
property = when (segment) {
"properties" -> {
if (!pathIterator.hasNext()) throw IllegalArgumentException("Missing property name after 'properties' in $originalRef")
val propName = pathIterator.next()
property.properties?.get(propName)
?: throw ClassNotFoundException("Property '$propName' not found under '${property.id ?: "unknown"}' at path $originalRef")
}
"items" -> property.items
?: throw ClassNotFoundException("Property 'items' not found under '${property.id ?: "unknown"}' at path $originalRef")
else -> throw IllegalArgumentException("Unknown json-object property '$segment' in path $originalRef")
}
}
return property
}
}

Loading
Loading