Skip to content

Commit e05439c

Browse files
[java][jersey2] Add support for discriminator, fix nullable typo and nullable deserialization (#6495)
* Mustache template should use invokerPackage tag to generate import * fix typo, fix script issue, add log statement for troubleshooting * Add java jersey2 samples with OpenAPI doc that has HTTP signature security scheme * Add sample for Java jersey2 and HTTP signature scheme * Add unit test for oneOf schema deserialization * Add unit test for oneOf schema deserialization * Add log statements * Add profile for jersey2 * Temporarily disable unit test * Temporarily disable unit test * support for discriminator in jersey2 * fix typo in pom.xml * disable unit test because jersey2 deserialization is broken * disable unit test because jersey2 deserialization is broken * fix duplicate jersey2 samples * fix duplicate jersey2 samples * Add code comments * fix duplicate artifact id * fix duplicate jersey2 samples * run samples scripts * resolve merge conflicts * Add unit tests * fix unit tests * continue implementation of discriminator lookup * throw deserialization exception when value is null and schema does not allow null value * continue implementation of compose schema * continue implementation of compose schema * continue implementation of compose schema * Add more unit tests * Add unit tests for anyOf * Add unit tests Co-authored-by: Vikrant Balyan (vvb) <[email protected]>
1 parent c1cf63e commit e05439c

File tree

21 files changed

+1701
-105
lines changed

21 files changed

+1701
-105
lines changed

CI/samples.ci/client/petstore/java/test-manual/jersey2-java8/JSONComposedSchemaTest.java

Lines changed: 291 additions & 31 deletions
Large diffs are not rendered by default.

modules/openapi-generator/src/main/resources/Java/libraries/jersey2/AbstractOpenApiSchema.mustache

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,29 +30,29 @@ public abstract class AbstractOpenApiSchema {
3030
this.isNullable = isNullable;
3131
}
3232

33-
/***
34-
* Get the list of schemas allowed to be stored in this object
33+
/**
34+
* Get the list of oneOf/anyOf composed schemas allowed to be stored in this object
3535
*
3636
* @return an instance of the actual schema/object
3737
*/
3838
public abstract Map<String, GenericType> getSchemas();
3939

40-
/***
40+
/**
4141
* Get the actual instance
4242
*
4343
* @return an instance of the actual schema/object
4444
*/
4545
@JsonValue
4646
public Object getActualInstance() {return instance;}
4747

48-
/***
48+
/**
4949
* Set the actual instance
5050
*
5151
* @param instance the actual instance of the schema/object
5252
*/
5353
public void setActualInstance(Object instance) {this.instance = instance;}
5454

55-
/***
55+
/**
5656
* Get the schema type (e.g. anyOf, oneOf)
5757
*
5858
* @return the schema type
@@ -101,7 +101,7 @@ public abstract class AbstractOpenApiSchema {
101101
return Objects.hash(instance, isNullable, schemaType);
102102
}
103103

104-
/***
104+
/**
105105
* Is nullalble
106106
*
107107
* @return true if it's nullable

modules/openapi-generator/src/main/resources/Java/libraries/jersey2/JSON.mustache

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@ import com.fasterxml.jackson.datatype.joda.JodaModule;
1515
{{#threetenbp}}
1616
import com.fasterxml.jackson.datatype.threetenbp.ThreeTenModule;
1717
{{/threetenbp}}
18+
{{#models.0}}
19+
import {{modelPackage}}.*;
20+
{{/models.0}}
1821

1922
import java.text.DateFormat;
20-
23+
import java.util.HashMap;
24+
import java.util.HashSet;
25+
import java.util.Map;
26+
import java.util.Set;
27+
import javax.ws.rs.core.GenericType;
2128
import javax.ws.rs.ext.ContextResolver;
2229

2330
{{>generatedAnnotation}}
@@ -69,4 +76,180 @@ public class JSON implements ContextResolver<ObjectMapper> {
6976
* @return object mapper
7077
*/
7178
public ObjectMapper getMapper() { return mapper; }
79+
80+
/**
81+
* Returns the target model class that should be used to deserialize the input data.
82+
* The discriminator mappings are used to determine the target model class.
83+
*
84+
* @param node The input data.
85+
* @param modelClass The class that contains the discriminator mappings.
86+
*/
87+
public static Class getClassForElement(JsonNode node, Class modelClass) {
88+
ClassDiscriminatorMapping cdm = modelDiscriminators.get(modelClass);
89+
if (cdm != null) {
90+
return cdm.getClassForElement(node, new HashSet<Class>());
91+
}
92+
return null;
93+
}
94+
95+
/**
96+
* Helper class to register the discriminator mappings.
97+
*/
98+
private static class ClassDiscriminatorMapping {
99+
// The model class name.
100+
Class modelClass;
101+
// The name of the discriminator property.
102+
String discriminatorName;
103+
// The discriminator mappings for a model class.
104+
Map<String, Class> discriminatorMappings;
105+
106+
// Constructs a new class discriminator.
107+
ClassDiscriminatorMapping(Class cls, String name) {
108+
modelClass = cls;
109+
discriminatorName = name;
110+
discriminatorMappings = new HashMap<String, Class>();
111+
}
112+
113+
// Register a discriminator mapping for the specified model class.
114+
void registerMapping(String mapping, Class cls) {
115+
discriminatorMappings.put(mapping, cls);
116+
}
117+
118+
// Return the name of the discriminator property for this model class.
119+
String getDiscriminatorPropertyName() {
120+
return discriminatorName;
121+
}
122+
123+
// Return the discriminator value or null if the discriminator is not
124+
// present in the payload.
125+
String getDiscriminatorValue(JsonNode node) {
126+
// Determine the value of the discriminator property in the input data.
127+
if (discriminatorName != null) {
128+
// Get the value of the discriminator property, if present in the input payload.
129+
node = node.get(discriminatorName);
130+
if (node != null && node.isValueNode()) {
131+
String discrValue = node.asText();
132+
if (discrValue != null) {
133+
return discrValue;
134+
}
135+
}
136+
}
137+
return null;
138+
}
139+
140+
/**
141+
* Returns the target model class that should be used to deserialize the input data.
142+
* This function can be invoked for anyOf/oneOf composed models with discriminator mappings.
143+
* The discriminator mappings are used to determine the target model class.
144+
*
145+
* @param node The input data.
146+
* @param visitedClasses The set of classes that have already been visited.
147+
*/
148+
Class getClassForElement(JsonNode node, Set<Class> visitedClasses) {
149+
if (visitedClasses.contains(modelClass)) {
150+
// Class has already been visited.
151+
return null;
152+
}
153+
// Determine the value of the discriminator property in the input data.
154+
String discrValue = getDiscriminatorValue(node);
155+
if (discrValue == null) {
156+
return null;
157+
}
158+
Class cls = discriminatorMappings.get(discrValue);
159+
// It may not be sufficient to return this cls directly because that target class
160+
// may itself be a composed schema, possibly with its own discriminator.
161+
visitedClasses.add(modelClass);
162+
for (Class childClass : discriminatorMappings.values()) {
163+
ClassDiscriminatorMapping childCdm = modelDiscriminators.get(childClass);
164+
if (childCdm == null) {
165+
continue;
166+
}
167+
if (!discriminatorName.equals(childCdm.discriminatorName)) {
168+
discrValue = getDiscriminatorValue(node);
169+
if (discrValue == null) {
170+
continue;
171+
}
172+
}
173+
if (childCdm != null) {
174+
// Recursively traverse the discriminator mappings.
175+
Class childDiscr = childCdm.getClassForElement(node, visitedClasses);
176+
if (childDiscr != null) {
177+
return childDiscr;
178+
}
179+
}
180+
}
181+
return cls;
182+
}
183+
}
184+
185+
/**
186+
* Returns true if inst is an instance of modelClass in the OpenAPI model hierarchy.
187+
*
188+
* The Java class hierarchy is not implemented the same way as the OpenAPI model hierarchy,
189+
* so it's not possible to use the instanceof keyword.
190+
*
191+
* @param modelClass A OpenAPI model class.
192+
* @param inst The instance object.
193+
*/
194+
public static boolean isInstanceOf(Class modelClass, Object inst, Set<Class> visitedClasses) {
195+
if (modelClass.isInstance(inst)) {
196+
// This handles the 'allOf' use case with single parent inheritance.
197+
return true;
198+
}
199+
if (visitedClasses.contains(modelClass)) {
200+
// This is to prevent infinite recursion when the composed schemas have
201+
// a circular dependency.
202+
return false;
203+
}
204+
visitedClasses.add(modelClass);
205+
206+
// Traverse the oneOf/anyOf composed schemas.
207+
Map<String, GenericType> descendants = modelDescendants.get(modelClass);
208+
if (descendants != null) {
209+
for (GenericType childType : descendants.values()) {
210+
if (isInstanceOf(childType.getRawType(), inst, visitedClasses)) {
211+
return true;
212+
}
213+
}
214+
}
215+
return false;
216+
}
217+
218+
private static Map<Class, ClassDiscriminatorMapping> modelDiscriminators = new HashMap<Class, ClassDiscriminatorMapping>();
219+
220+
/**
221+
* Register the discriminators for all composed models.
222+
*/
223+
private static void registerDiscriminators() {
224+
{{#models}}
225+
{{#model}}
226+
{{#discriminator}}
227+
{
228+
// Initialize the discriminator mappings for '{{classname}}'.
229+
ClassDiscriminatorMapping m = new ClassDiscriminatorMapping({{classname}}.class, "{{propertyBaseName}}");
230+
{{#mappedModels}}
231+
m.registerMapping("{{mappingName}}", {{modelName}}.class);
232+
{{/mappedModels}}
233+
m.registerMapping("{{name}}", {{classname}}.class);
234+
modelDiscriminators.put({{classname}}.class, m);
235+
}
236+
{{/discriminator}}
237+
{{/model}}
238+
{{/models}}
239+
}
240+
241+
private static Map<Class, Map<String, GenericType>> modelDescendants = new HashMap<Class, Map<String, GenericType>>();
242+
243+
/**
244+
* Register the oneOf/anyOf descendants.
245+
* TODO: this should not be a public method.
246+
*/
247+
public static void registerDescendants(Class modelClass, Map<String, GenericType> descendants) {
248+
modelDescendants.put(modelClass, descendants);
249+
}
250+
251+
static {
252+
registerDiscriminators();
253+
}
254+
72255
}

modules/openapi-generator/src/main/resources/Java/libraries/jersey2/anyof_model.mustache

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import java.io.IOException;
44
import java.util.logging.Level;
55
import java.util.logging.Logger;
66
import java.util.ArrayList;
7+
import java.util.Collections;
78
import java.util.HashMap;
9+
import java.util.HashSet;
810
import java.util.Map;
11+
import {{invokerPackage}}.JSON;
912

1013
import com.fasterxml.jackson.core.JsonParser;
1114
import com.fasterxml.jackson.core.JsonProcessingException;
1215
import com.fasterxml.jackson.databind.DeserializationContext;
16+
import com.fasterxml.jackson.databind.JsonMappingException;
1317
import com.fasterxml.jackson.databind.JsonNode;
1418
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
1519
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
@@ -33,6 +37,18 @@ public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-im
3337
JsonNode tree = jp.readValueAsTree();
3438
3539
Object deserialized = null;
40+
{{#discriminator}}
41+
Class cls = JSON.getClassForElement(tree, {{classname}}.class);
42+
if (cls != null) {
43+
// When the OAS schema includes a discriminator, use the discriminator value to
44+
// discriminate the anyOf schemas.
45+
// Get the discriminator mapping value to get the class.
46+
deserialized = tree.traverse(jp.getCodec()).readValueAs(cls);
47+
{{classname}} ret = new {{classname}}();
48+
ret.setActualInstance(deserialized);
49+
return ret;
50+
}
51+
{{/discriminator}}
3652
{{#anyOf}}
3753
// deserialzie {{{.}}}
3854
try {
@@ -48,6 +64,19 @@ public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-im
4864
{{/anyOf}}
4965
throw new IOException(String.format("Failed deserialization for {{classname}}: no match found"));
5066
}
67+
68+
/**
69+
* Handle deserialization of the 'null' value.
70+
*/
71+
@Override
72+
public {{classname}} getNullValue(DeserializationContext ctxt) throws JsonMappingException {
73+
{{#isNullable}}
74+
return null;
75+
{{/isNullable}}
76+
{{^isNullable}}
77+
throw new JsonMappingException("{{classname}} cannot be null");
78+
{{/isNullable}}
79+
}
5180
}
5281

5382
// store a list of schema names defined in anyOf
@@ -69,6 +98,7 @@ public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-im
6998
schemas.put("{{{.}}}", new GenericType<{{{.}}}>() {
7099
});
71100
{{/anyOf}}
101+
JSON.registerDescendants({{classname}}.class, Collections.unmodifiableMap(schemas));
72102
}
73103

74104
@Override
@@ -78,15 +108,15 @@ public class {{classname}} extends AbstractOpenApiSchema{{#vendorExtensions.x-im
78108

79109
@Override
80110
public void setActualInstance(Object instance) {
81-
{{#isNulalble}}
111+
{{#isNullable}}
82112
if (instance == null) {
83113
super.setActualInstance(instance);
84114
return;
85115
}
86116

87-
{{/isNulalble}}
117+
{{/isNullable}}
88118
{{#anyOf}}
89-
if (instance instanceof {{{.}}}) {
119+
if (JSON.isInstanceOf({{{.}}}.class, instance, new HashSet<Class>())) {
90120
super.setActualInstance(instance);
91121
return;
92122
}

0 commit comments

Comments
 (0)