Skip to content

Commit 6d413bc

Browse files
kezhenxu94wuseal
authored andcommitted
[GH-108] Generate Kotlin classes from JSON Schema (#126)
* support generating class from JsonSchema * Sync * support generating class from JsonSchema * Sync * Remove checking properties order * Add unit test * Support array * Add more UT * Update Kotlin version * Fix UT * Fix UT * Add UT * Reduce stacktrace * Default value * Simpify type name * Default value * Fix CI
1 parent ea7bb35 commit 6d413bc

File tree

18 files changed

+782
-16
lines changed

18 files changed

+782
-16
lines changed

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ repositories {
4747
}
4848

4949
dependencies {
50+
compile 'com.squareup:kotlinpoet:1.1.0'
51+
5052
testImplementation('com.winterbe:expekt:0.5.0') {
5153
exclude group: "org.jetbrains.kotlin"
5254
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
/*
2+
* Copyright (C) 2011 Google Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package thirdparties
18+
19+
import java.io.IOException
20+
import java.util.LinkedHashMap
21+
22+
import com.google.gson.Gson
23+
import com.google.gson.JsonElement
24+
import com.google.gson.JsonObject
25+
import com.google.gson.JsonParseException
26+
import com.google.gson.JsonPrimitive
27+
import com.google.gson.TypeAdapter
28+
import com.google.gson.TypeAdapterFactory
29+
import com.google.gson.internal.Streams
30+
import com.google.gson.reflect.TypeToken
31+
import com.google.gson.stream.JsonReader
32+
import com.google.gson.stream.JsonWriter
33+
34+
/**
35+
* Adapts values whose runtime type may differ from their declaration type. This
36+
* is necessary when a field's type is not the same type that GSON should create
37+
* when deserializing that field. For example, consider these types:
38+
* <pre> `abstract class Shape {
39+
* int x;
40+
* int y;
41+
* }
42+
* class Circle extends Shape {
43+
* int radius;
44+
* }
45+
* class Rectangle extends Shape {
46+
* int width;
47+
* int height;
48+
* }
49+
* class Diamond extends Shape {
50+
* int width;
51+
* int height;
52+
* }
53+
* class Drawing {
54+
* Shape bottomShape;
55+
* Shape topShape;
56+
* }
57+
`</pre> *
58+
*
59+
* Without additional type information, the serialized JSON is ambiguous. Is
60+
* the bottom shape in this drawing a rectangle or a diamond? <pre> `{
61+
* "bottomShape": {
62+
* "width": 10,
63+
* "height": 5,
64+
* "x": 0,
65+
* "y": 0
66+
* },
67+
* "topShape": {
68+
* "radius": 2,
69+
* "x": 4,
70+
* "y": 1
71+
* }
72+
* }`</pre>
73+
* This class addresses this problem by adding type information to the
74+
* serialized JSON and honoring that type information when the JSON is
75+
* deserialized: <pre> `{
76+
* "bottomShape": {
77+
* "type": "Diamond",
78+
* "width": 10,
79+
* "height": 5,
80+
* "x": 0,
81+
* "y": 0
82+
* },
83+
* "topShape": {
84+
* "type": "Circle",
85+
* "radius": 2,
86+
* "x": 4,
87+
* "y": 1
88+
* }
89+
* }`</pre>
90+
* Both the type field name (`"type"`) and the type labels (`"Rectangle"`) are configurable.
91+
*
92+
* <h3>Registering Types</h3>
93+
* Create a `RuntimeTypeAdapterFactory` by passing the base type and type field
94+
* name to the [.of] factory method. If you don't supply an explicit type
95+
* field name, `"type"` will be used. <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory
96+
* = RuntimeTypeAdapterFactory.of(Shape.class, "type");
97+
`</pre> *
98+
* Next register all of your subtypes. Every subtype must be explicitly
99+
* registered. This protects your application from injection attacks. If you
100+
* don't supply an explicit type label, the type's simple name will be used.
101+
* <pre> `shapeAdapterFactory.registerSubtype(Rectangle.class, "Rectangle");
102+
* shapeAdapterFactory.registerSubtype(Circle.class, "Circle");
103+
* shapeAdapterFactory.registerSubtype(Diamond.class, "Diamond");
104+
`</pre> *
105+
* Finally, register the type adapter factory in your application's GSON builder:
106+
* <pre> `Gson gson = new GsonBuilder()
107+
* .registerTypeAdapterFactory(shapeAdapterFactory)
108+
* .create();
109+
`</pre> *
110+
* Like `GsonBuilder`, this API supports chaining: <pre> `RuntimeTypeAdapterFactory<Shape> shapeAdapterFactory = RuntimeTypeAdapterFactory.of(Shape.class)
111+
* .registerSubtype(Rectangle.class)
112+
* .registerSubtype(Circle.class)
113+
* .registerSubtype(Diamond.class);
114+
`</pre> *
115+
*/
116+
class RuntimeTypeAdapterFactory<T> private constructor(private val baseType: Class<*>?, private val typeFieldName: String?, private val maintainType: Boolean) : TypeAdapterFactory {
117+
private val labelToSubtype = LinkedHashMap<String, Class<*>>()
118+
private val subtypeToLabel = LinkedHashMap<Class<*>, String>()
119+
120+
init {
121+
if (typeFieldName == null || baseType == null) {
122+
throw NullPointerException()
123+
}
124+
}
125+
126+
/**
127+
* Registers `type` identified by `label`. Labels are case
128+
* sensitive.
129+
*
130+
* @throws IllegalArgumentException if either `type` or `label`
131+
* have already been registered on this type adapter.
132+
*/
133+
@JvmOverloads
134+
fun registerSubtype(type: Class<out T>?, label: String? = type?.getSimpleName()): RuntimeTypeAdapterFactory<T> {
135+
if (type == null || label == null) {
136+
throw NullPointerException()
137+
}
138+
if (subtypeToLabel.containsKey(type) || labelToSubtype.containsKey(label)) {
139+
throw IllegalArgumentException("types and labels must be unique")
140+
}
141+
labelToSubtype[label] = type
142+
subtypeToLabel[type] = label
143+
return this
144+
}
145+
146+
override fun <R : Any> create(gson: Gson, type: TypeToken<R>): TypeAdapter<R>? {
147+
if (type.rawType != baseType) {
148+
return null
149+
}
150+
151+
val labelToDelegate = LinkedHashMap<String, TypeAdapter<*>>()
152+
val subtypeToDelegate = LinkedHashMap<Class<*>, TypeAdapter<*>>()
153+
for ((key, value) in labelToSubtype) {
154+
val delegate = gson.getDelegateAdapter(this, TypeToken.get(value))
155+
labelToDelegate[key] = delegate
156+
subtypeToDelegate[value] = delegate
157+
}
158+
159+
return object : TypeAdapter<R>() {
160+
@Throws(IOException::class)
161+
override fun read(`in`: JsonReader): R {
162+
val jsonElement = Streams.parse(`in`)
163+
val labelJsonElement: JsonElement?
164+
if (maintainType) {
165+
labelJsonElement = jsonElement.asJsonObject.get(typeFieldName)
166+
} else {
167+
labelJsonElement = jsonElement.asJsonObject.remove(typeFieldName)
168+
}
169+
170+
if (labelJsonElement == null) {
171+
throw JsonParseException("cannot deserialize " + baseType
172+
+ " because it does not define a field named " + typeFieldName)
173+
}
174+
val label = labelJsonElement.asString
175+
val delegate = labelToDelegate[label] as? TypeAdapter<R>
176+
?: throw JsonParseException("cannot deserialize " + baseType + " subtype named "
177+
+ label + "; did you forget to register a subtype?")// registration requires that subtype extends T
178+
return delegate.fromJsonTree(jsonElement)
179+
}
180+
181+
@Throws(IOException::class)
182+
override fun write(out: JsonWriter, value: R) {
183+
val srcType = value.javaClass
184+
val label = subtypeToLabel[srcType]
185+
val delegate = subtypeToDelegate[srcType] as? TypeAdapter<R>
186+
?: throw JsonParseException("cannot serialize " + srcType.getName()
187+
+ "; did you forget to register a subtype?")// registration requires that subtype extends T
188+
val jsonObject = delegate.toJsonTree(value).asJsonObject
189+
190+
if (maintainType) {
191+
Streams.write(jsonObject, out)
192+
return
193+
}
194+
195+
val clone = JsonObject()
196+
197+
if (jsonObject.has(typeFieldName)) {
198+
throw JsonParseException("cannot serialize " + srcType.getName()
199+
+ " because it already defines a field named " + typeFieldName)
200+
}
201+
clone.add(typeFieldName, JsonPrimitive(label))
202+
203+
for ((key, value1) in jsonObject.entrySet()) {
204+
clone.add(key, value1)
205+
}
206+
Streams.write(clone, out)
207+
}
208+
}.nullSafe()
209+
}
210+
211+
companion object {
212+
213+
/**
214+
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
215+
* `maintainType` flag decide if the type will be stored in pojo or not.
216+
*/
217+
fun <T> of(baseType: Class<T>, typeFieldName: String, maintainType: Boolean): RuntimeTypeAdapterFactory<T> {
218+
return RuntimeTypeAdapterFactory(baseType, typeFieldName, maintainType)
219+
}
220+
221+
/**
222+
* Creates a new runtime type adapter using for `baseType` using `typeFieldName` as the type field name. Type field names are case sensitive.
223+
*/
224+
fun <T> of(baseType: Class<T>, typeFieldName: String): RuntimeTypeAdapterFactory<T> {
225+
return RuntimeTypeAdapterFactory(baseType, typeFieldName, false)
226+
}
227+
228+
/**
229+
* Creates a new runtime type adapter for `baseType` using `"type"` as
230+
* the type field name.
231+
*/
232+
fun <T> of(baseType: Class<T>): RuntimeTypeAdapterFactory<T> {
233+
return RuntimeTypeAdapterFactory(baseType, "type", false)
234+
}
235+
}
236+
}
237+
/**
238+
* Registers `type` identified by its [simple][Class.getSimpleName]. Labels are case sensitive.
239+
*
240+
* @throws IllegalArgumentException if either `type` or its simple name
241+
* have already been registered on this type adapter.
242+
*/
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* Created by kezhenxu94 at 2019/4/17 10:20.
3+
*
4+
* Package to hold some portable classes from third party library
5+
* that are not deployed to maven
6+
*
7+
* @author kezhenxu94 (kezhenxu94 at 163 dot com)
8+
*/
9+
package thirdparties;

src/main/kotlin/wu/seal/jsontokotlin/GenerateKotlinFileAction.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,9 @@ class GenerateKotlinFileAction : AnAction("GenerateKotlinClassFile") {
7676
psiFileFactory: PsiFileFactory,
7777
directory: PsiDirectory
7878
) {
79-
val codeMaker = KotlinCodeMaker(className, json)
80-
val removeDuplicateClassCode = ClassCodeFilter.removeDuplicateClassCode(codeMaker.makeKotlinData())
79+
val generatedClassesString = KotlinCodeMaker(className, json).makeKotlinData()
80+
81+
val removeDuplicateClassCode = ClassCodeFilter.removeDuplicateClassCode(generatedClassesString)
8182

8283
if (ConfigManager.isInnerClassModel) {
8384

@@ -102,4 +103,4 @@ class GenerateKotlinFileAction : AnAction("GenerateKotlinClassFile") {
102103

103104
}
104105
}
105-
}
106+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package wu.seal.jsontokotlin
2+
3+
import com.squareup.kotlinpoet.*
4+
import wu.seal.jsontokotlin.bean.jsonschema.*
5+
import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
6+
7+
class JsonSchemaDataClassGenerator(private val jsonObjectDef: ObjectPropertyDef) {
8+
val classes = mutableListOf<TypeSpec>()
9+
10+
fun generate(className: String?) {
11+
val clazz = className
12+
?: throw IllegalArgumentException("className cannot be null when jsonObjectDef.title is null")
13+
14+
val requiredFields = jsonObjectDef.required ?: emptyArray() // don't remove `?: emptyArray()`
15+
val properties = jsonObjectDef.properties
16+
val s = TypeSpec.classBuilder(clazz).apply {
17+
if (!ConfigManager.isCommentOff && (jsonObjectDef.description?.isNotBlank() == true)) {
18+
addKdoc(jsonObjectDef.description)
19+
}
20+
addModifiers(KModifier.DATA)
21+
primaryConstructor(FunSpec.constructorBuilder().apply {
22+
properties.forEach { property, propertyDefinition ->
23+
val typeName = resolveType(property, requiredFields, propertyDefinition)
24+
addParameter(property, typeName)
25+
if (propertyDefinition::class === ObjectPropertyDef::class) {
26+
val jsonSchemaDataClassGenerator = JsonSchemaDataClassGenerator(propertyDefinition as ObjectPropertyDef)
27+
jsonSchemaDataClassGenerator.generate((typeName as? ClassName)?.simpleName)
28+
classes.addAll(jsonSchemaDataClassGenerator.classes)
29+
}
30+
}
31+
}.build())
32+
properties.forEach { property, propertyDefinition ->
33+
val typeName = resolveType(property, requiredFields, propertyDefinition)
34+
val description = (propertyDefinition as? ObjectPropertyDef)?.description
35+
addProperty(
36+
PropertySpec.builder(property, typeName).apply {
37+
if (!ConfigManager.isCommentOff && (description?.isNotBlank() == true)) {
38+
addKdoc(description)
39+
}
40+
initializer(property)
41+
}.build()
42+
)
43+
}
44+
}.build()
45+
classes.add(s)
46+
}
47+
48+
private fun resolveType(property: String, requiredFields: Array<String>, propertyDefinition: PropertyDef): TypeName {
49+
val nullable = property !in requiredFields
50+
return when (propertyDefinition) {
51+
is IntPropertyDef -> ClassName.bestGuess("Int").copy(nullable = nullable)
52+
is NumberPropertyDef -> ClassName.bestGuess("Double").copy(nullable = nullable)
53+
is BoolPropertyDef -> ClassName.bestGuess("Boolean").copy(nullable = nullable)
54+
is StringPropertyDef -> ClassName.bestGuess("String").copy(nullable = nullable)
55+
is EnumPropertyDef -> ClassName.bestGuess("String").copy(nullable = nullable)
56+
is ArrayPropertyDef -> {
57+
val arrayParameterType = resolveType("", arrayOf(""), propertyDefinition.items)
58+
ClassName.bestGuess("Array").parameterizedBy(arrayParameterType).copy(nullable = nullable)
59+
}
60+
is ObjectPropertyDef -> ClassName("", property.capitalize()).copy(nullable = nullable)
61+
else -> String::class.asTypeName().copy(nullable = nullable)
62+
}
63+
}
64+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package wu.seal.jsontokotlin
2+
3+
/**
4+
* Created by kezhenxu94 in 2019-03-28 22:21
5+
*
6+
* @author kezhenxu94 (kezhenxu94 at 163 dot com)
7+
*/
8+
val JSON_SCHEMA_TYPE_MAPPINGS = mapOf(
9+
"object" to Any::class,
10+
"array" to Array<Any>::class,
11+
"string" to String::class,
12+
"integer" to Int::class,
13+
"number" to Double::class,
14+
"boolean" to Boolean::class,
15+
"enum" to Enum::class
16+
)

0 commit comments

Comments
 (0)