Skip to content

Commit 6cd4d99

Browse files
committed
Fix JSON Schema reference resolution
- Add support for newer JSON Schema format using instead of definitions - Create helper method to combine both definitions and into a single map - Enhance JSON Schema detection to recognize schemas with - Add comprehensive test cases for support - Fix crash when resolving references to #// paths
1 parent 0b0ed5b commit 6cd4d99

File tree

3 files changed

+320
-4
lines changed

3 files changed

+320
-4
lines changed

src/main/kotlin/wu/seal/jsontokotlin/model/jsonschema/JsonSchema.kt

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,33 @@ import com.google.gson.annotations.SerializedName
66
class JsonSchema(
77
@SerializedName("\$schema")
88
val schema: String? = null,
9-
val definitions: Map<String, PropertyDef>
9+
val definitions: Map<String, PropertyDef> = emptyMap(),
10+
@SerializedName("\$defs")
11+
val defs: Map<String, PropertyDef>? = null
1012
) : JsonObjectDef() {
1113

14+
// Get a combined map of both definitions and $defs
15+
// This allows backward compatibility with older schema versions
16+
private val allDefinitions: Map<String, PropertyDef>
17+
get() {
18+
val combinedMap = definitions.toMutableMap()
19+
defs?.let { combinedMap.putAll(it) }
20+
return combinedMap
21+
}
22+
1223
//See: https://json-schema.org/understanding-json-schema/structuring.html
1324
fun resolveDefinition(ref: String): PropertyDef {
1425
if (ref.length < 2) throw IllegalArgumentException("Bad ref: $ref")
1526
if (!ref.startsWith("#")) throw NotImplementedError("Not local definitions are not supported (ref: $ref)")
1627

1728
val path = ref.split('/')
1829
return when {
19-
path.count() == 1 -> definitions.values.firstOrNull { it.id == path[0] }
30+
path.count() == 1 -> allDefinitions.values.firstOrNull { it.id == path[0] }
31+
?: throw ClassNotFoundException("Definition $ref not found")
32+
path[1] == "definitions" -> definitions[path[2]]
33+
?: throw ClassNotFoundException("Definition $ref not found")
34+
path[1] == "\$defs" -> defs?.get(path[2])
2035
?: throw ClassNotFoundException("Definition $ref not found")
21-
path[1] == "definitions" -> definitions[path[2]] ?: throw ClassNotFoundException("Definition $ref not found")
2236
path[1] == "properties" -> {
2337
var property: PropertyDef = properties?.get(path[2])
2438
?: throw ClassNotFoundException("Definition $ref not found")

src/main/kotlin/wu/seal/jsontokotlin/utils/Extensions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,7 @@ fun String.isJSONSchema(): Boolean {
325325
val jsonElement = Gson().fromJson(this, JsonElement::class.java)
326326
return if (jsonElement.isJsonObject) {
327327
with(jsonElement.asJsonObject) {
328-
has("\$schema")
328+
has("\$schema") || has("\$defs") || has("definitions")
329329
}
330330
} else {
331331
false

src/test/kotlin/wu/seal/jsontokotlin/JsonSchemaTest.kt

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package wu.seal.jsontokotlin
33
import org.junit.Before
44
import org.junit.Test
55
import wu.seal.jsontokotlin.test.TestConfig
6+
import com.google.gson.annotations.SerializedName
67

78
class JsonSchemaTest {
89

@@ -61,4 +62,305 @@ class JsonSchemaTest {
6162
val s = json.generateKotlinClassCode("TestData")
6263
println(s)
6364
}
65+
66+
@Test
67+
fun testJsonSchemaWithDefs() {
68+
val json = """{
69+
"${"$"}schema": "http://json-schema.org/draft-2020-12/schema",
70+
"type": "object",
71+
"title": "Payment",
72+
"description": "A payment instruction with transaction details",
73+
"properties": {
74+
"paymentId": {
75+
"type": "string",
76+
"description": "Unique identifier for the payment"
77+
},
78+
"amount": {
79+
"type": "number",
80+
"description": "Payment amount"
81+
},
82+
"currency": {
83+
"type": "string",
84+
"description": "Payment currency code"
85+
},
86+
"transaction": {
87+
"${"$"}ref": "#/${"$"}defs/Transaction"
88+
}
89+
},
90+
"required": ["paymentId", "amount", "currency", "transaction"],
91+
"${"$"}defs": {
92+
"Transaction": {
93+
"type": "object",
94+
"properties": {
95+
"transactionId": {
96+
"type": "string",
97+
"description": "Unique identifier for the transaction"
98+
},
99+
"status": {
100+
"type": "string",
101+
"enum": ["PENDING", "COMPLETED", "FAILED"],
102+
"description": "Current status of the transaction"
103+
},
104+
"details": {
105+
"${"$"}ref": "#/${"$"}defs/TransactionDetails"
106+
}
107+
},
108+
"required": ["transactionId", "status"]
109+
},
110+
"TransactionDetails": {
111+
"type": "object",
112+
"properties": {
113+
"createdAt": {
114+
"type": "string",
115+
"format": "date-time",
116+
"description": "Transaction creation timestamp"
117+
},
118+
"updatedAt": {
119+
"type": "string",
120+
"format": "date-time",
121+
"description": "Transaction last update timestamp"
122+
},
123+
"notes": {
124+
"type": "string",
125+
"description": "Additional notes about the transaction"
126+
}
127+
}
128+
}
129+
}
130+
}""".trimIndent()
131+
132+
val expected = """/**
133+
* A payment instruction with transaction details
134+
*/
135+
data class Payment(
136+
/**
137+
* Payment amount
138+
*/
139+
@SerializedName("amount")
140+
val amount: Double = 0.0,
141+
/**
142+
* Payment currency code
143+
*/
144+
@SerializedName("currency")
145+
val currency: String = "",
146+
/**
147+
* Unique identifier for the payment
148+
*/
149+
@SerializedName("paymentId")
150+
val paymentId: String = "",
151+
@SerializedName("transaction")
152+
val transaction: Transaction = Transaction()
153+
) {
154+
data class Transaction(
155+
@SerializedName("details")
156+
val details: TransactionDetails = TransactionDetails(),
157+
/**
158+
* Current status of the transaction
159+
*/
160+
@SerializedName("status")
161+
val status: Status = Status(),
162+
/**
163+
* Unique identifier for the transaction
164+
*/
165+
@SerializedName("transactionId")
166+
val transactionId: String = ""
167+
) {
168+
data class TransactionDetails(
169+
/**
170+
* Transaction creation timestamp
171+
*/
172+
@SerializedName("createdAt")
173+
val createdAt: java.time.OffsetDateTime = OffsetDateTime(),
174+
/**
175+
* Additional notes about the transaction
176+
*/
177+
@SerializedName("notes")
178+
val notes: String = "",
179+
/**
180+
* Transaction last update timestamp
181+
*/
182+
@SerializedName("updatedAt")
183+
val updatedAt: java.time.OffsetDateTime = OffsetDateTime()
184+
)
185+
186+
/**
187+
* Current status of the transaction
188+
*/
189+
enum class Status(val value: String) {
190+
PENDING("PENDING"),
191+
192+
COMPLETED("COMPLETED"),
193+
194+
FAILED("FAILED");
195+
}
196+
}
197+
}""".trimIndent()
198+
199+
val result = json.generateKotlinClassCode("Payment")
200+
println(result)
201+
202+
// Use assertion to verify that the generated code matches the expected result
203+
assert(result.trim() == expected) { "Generated code does not match expected output" }
204+
}
205+
206+
@Test
207+
fun testComplexJsonSchemaWithDefs() {
208+
// This test simulates the case from the crash report involving #/$defs/CreditTransferTransaction39
209+
val json = """{
210+
"${"$"}schema": "http://json-schema.org/draft-2020-12/schema",
211+
"title": "PaymentInstruction",
212+
"type": "object",
213+
"properties": {
214+
"msgId": {
215+
"type": "string",
216+
"description": "Point to point reference assigned by the instructing party to unambiguously identify the instruction"
217+
},
218+
"pmtInf": {
219+
"type": "array",
220+
"items": {
221+
"${"$"}ref": "#/${"$"}defs/PaymentInformation"
222+
}
223+
}
224+
},
225+
"required": ["msgId", "pmtInf"],
226+
"${"$"}defs": {
227+
"PaymentInformation": {
228+
"type": "object",
229+
"properties": {
230+
"pmtInfId": {
231+
"type": "string",
232+
"description": "Unique identification assigned by the sending party to unambiguously identify the payment information group"
233+
},
234+
"pmtMtd": {
235+
"type": "string",
236+
"description": "Specifies the means of payment that will be used to move the amount of money"
237+
},
238+
"cdtTrfTxInf": {
239+
"type": "array",
240+
"items": {
241+
"${"$"}ref": "#/${"$"}defs/CreditTransferTransaction"
242+
}
243+
}
244+
},
245+
"required": ["pmtInfId", "pmtMtd"]
246+
},
247+
"CreditTransferTransaction": {
248+
"type": "object",
249+
"properties": {
250+
"pmtId": {
251+
"type": "object",
252+
"properties": {
253+
"endToEndId": {
254+
"type": "string",
255+
"description": "Unique identification assigned by the initiating party to unambiguously identify the transaction"
256+
},
257+
"txId": {
258+
"type": "string",
259+
"description": "Unique identification assigned by the first instructing agent to unambiguously identify the transaction"
260+
}
261+
},
262+
"required": ["endToEndId"]
263+
},
264+
"amt": {
265+
"${"$"}ref": "#/${"$"}defs/ActiveCurrencyAndAmount"
266+
},
267+
"cdtr": {
268+
"${"$"}ref": "#/${"$"}defs/PartyIdentification"
269+
},
270+
"cdtrAcct": {
271+
"${"$"}ref": "#/${"$"}defs/CashAccount"
272+
}
273+
},
274+
"required": ["pmtId", "amt", "cdtr", "cdtrAcct"]
275+
},
276+
"ActiveCurrencyAndAmount": {
277+
"type": "object",
278+
"properties": {
279+
"ccy": {
280+
"type": "string",
281+
"description": "Medium of exchange of value, such as USD or EUR"
282+
},
283+
"value": {
284+
"type": "number",
285+
"description": "Amount of money to be moved between the debtor and creditor"
286+
}
287+
},
288+
"required": ["ccy", "value"]
289+
},
290+
"PartyIdentification": {
291+
"type": "object",
292+
"properties": {
293+
"nm": {
294+
"type": "string",
295+
"description": "Name by which a party is known"
296+
},
297+
"ctctDtls": {
298+
"type": "object",
299+
"properties": {
300+
"emailAdr": {
301+
"type": "string",
302+
"description": "Email address of the contact"
303+
},
304+
"phneNb": {
305+
"type": "string",
306+
"description": "Phone number of the contact"
307+
}
308+
}
309+
}
310+
},
311+
"required": ["nm"]
312+
},
313+
"CashAccount": {
314+
"type": "object",
315+
"properties": {
316+
"id": {
317+
"type": "object",
318+
"properties": {
319+
"iban": {
320+
"type": "string",
321+
"description": "International Bank Account Number (IBAN)"
322+
},
323+
"othr": {
324+
"type": "object",
325+
"properties": {
326+
"id": {
327+
"type": "string",
328+
"description": "Other account identification"
329+
}
330+
},
331+
"required": ["id"]
332+
}
333+
}
334+
},
335+
"tp": {
336+
"type": "object",
337+
"properties": {
338+
"cd": {
339+
"type": "string",
340+
"description": "Code specifying the nature or use of the account"
341+
}
342+
}
343+
},
344+
"ccy": {
345+
"type": "string",
346+
"description": "Identification of the currency in which the account is held"
347+
}
348+
},
349+
"required": ["id"]
350+
}
351+
}
352+
}""".trimIndent()
353+
354+
// Execute the code generation - if it doesn't throw an exception, our fix works
355+
val result = json.generateKotlinClassCode("PaymentInstruction")
356+
println(result)
357+
358+
// Verify some basic expectations about the output
359+
assert(result.contains("data class PaymentInstruction")) { "Missing PaymentInstruction class" }
360+
assert(result.contains("data class PaymentInformation")) { "Missing PaymentInformation class" }
361+
assert(result.contains("data class CreditTransferTransaction")) { "Missing CreditTransferTransaction class" }
362+
assert(result.contains("data class ActiveCurrencyAndAmount")) { "Missing ActiveCurrencyAndAmount class" }
363+
assert(result.contains("data class PartyIdentification")) { "Missing PartyIdentification class" }
364+
assert(result.contains("data class CashAccount")) { "Missing CashAccount class" }
365+
}
64366
}

0 commit comments

Comments
 (0)