Skip to content

Commit c1d47a5

Browse files
sebastien-rossetspacetherwing328
authored
[codegen][Python-experimental] Discriminator NPE fix, handle 'null' type, #4906 enhancements (#5809)
* Adds addComposedMappedModels and testComposedSchemaOneOfDiscriminatorMap * Requires that discriminators be required properties * Strengthens discriminaotr validation, adds better error messages, adds schema oneof samples * Adds oneOf and anyOf invalidDiscriminator tests * Runs ensure up to date * Updates incorrect addOneOfInterfaceModel invocation * Runs ensure-up-to-date * Fix NPE when at least one extension is defined but not x-discriminator-value * Adds addComposedMappedModels and testComposedSchemaOneOfDiscriminatorMap * Requires that discriminators be required properties * Strengthens discriminaotr validation, adds better error messages, adds schema oneof samples * Adds oneOf and anyOf invalidDiscriminator tests * Updates incorrect addOneOfInterfaceModel invocation * Runs ensure-up-to-date * Adds updates from Sebastien Rosset * Removes newlines * Add documentation and new getValidDiscriminatorMappings function * Add documentation and new getValidDiscriminatorMappings function * Add documentation and new getValidDiscriminatorMappings function * Add documentation and new getValidDiscriminatorMappings function * Add documentation and new getValidDiscriminatorMappings function * Add documentation and new getValidDiscriminatorMappings function * throw exception if discriminator mappingName argument is null * handle scenario when composed schema has 'null' type * remove extraneous characters in comments * Uses df.isString * Traverse discriminators to resolve discriminator mapping * Fixes tests be correctly setting df.isString * Remove unused method * Updates discriminatorExplicitMappingVerbose description per PR feedback * Adds description of how mappedModels is populated * Adds the suggestion exception raising when a MappedModel mappingName is null * Actually resolves merge conflicts * Adds addComposedMappedModels and testComposedSchemaOneOfDiscriminatorMap * Requires that discriminators be required properties * Strengthens discriminaotr validation, adds better error messages, adds schema oneof samples * Adds oneOf and anyOf invalidDiscriminator tests * Updates incorrect addOneOfInterfaceModel invocation * Runs ensure-up-to-date * Adds updates from Sebastien Rosset * Removes newlines * Uses df.isString * Fixes tests be correctly setting df.isString * Updates discriminatorExplicitMappingVerbose description per PR feedback * Adds description of how mappedModels is populated * Adds the suggestion exception raising when a MappedModel mappingName is null * Actually resolves merge conflicts * Switches two methods to package private because they are needed for testing * Allow nulls in MappedModel.getMappingName * Remove exception when mappingName is null value * Remove exception when mappingName is null value * resolve merge conflicts * resolve merge conflicts * Execute scripts in the bin directory * Fix CI issues and address PR review comments: better documentation and fix white space issues. * Fix CI issues and address PR review comments: better documentation and fix white space issues. * run sample scripts * resolve merge conflicts * fix end-of-line issue * resolve merge conflicts * resolve merge issues * Handle case when discriminator is not specified in input data * minor changes and add code comments * Refactor get_discriminator code * Add unit test with missing discriminator property * improve get_discriminator function * Run sample scripts * add unit tests for recursive get_discriminator_class * fix unit test issues * fix formatting issues * fix formatting issues * fix formatting issues * fix index out of range exception * fix formatting issues * fix formatting issues * fix formatting issues. Finally figured out how to check formatting in local workspace Co-authored-by: Justin Black <[email protected]> Co-authored-by: William Cheng <[email protected]>
1 parent 0d6f876 commit c1d47a5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1868
-259
lines changed

modules/openapi-generator/src/main/java/org/openapitools/codegen/CodegenModel.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222

2323
import java.util.*;
2424

25+
/**
26+
* CodegenModel represents a schema object in a OpenAPI document.
27+
*/
2528
@JsonIgnoreProperties({"parentModel", "interfaceModels"})
2629
public class CodegenModel implements IJsonSchemaValidationProperties {
2730
// The parent model name from the schemas. The parent is determined by inspecting the allOf, anyOf and
@@ -220,6 +223,45 @@ public void setDescription(String description) {
220223
this.description = description;
221224
}
222225

226+
/**
227+
* Returns the discriminator for this schema object, or null if no discriminator has been specified.
228+
*
229+
* The list of all possible schema discriminator mapping values is obtained
230+
* from explicit discriminator mapping values in the OpenAPI document, and from
231+
* inherited discriminators through oneOf, allOf, anyOf.
232+
* For example, a discriminator may be defined in a 'Pet' schema as shown below.
233+
* The Dog and Cat schemas inherit the discriminator through the allOf reference.
234+
* In the 'Pet' schema, the supported discriminator mapping values for the
235+
* 'objectType' properties are 'Dog' and 'Cat'.
236+
* The allowed discriminator mapping value for the Dog schema is 'Dog'.
237+
* The allowed discriminator mapping value for the Cat schema is 'Dog'.
238+
*
239+
* Pet:
240+
* type: object
241+
* discriminator:
242+
* propertyName: objectType
243+
* required:
244+
* - objectType
245+
* properties:
246+
* objectType:
247+
* type: string
248+
* Dog:
249+
* allOf:
250+
* - $ref: '#/components/schemas/Pet'
251+
* - type: object
252+
* properties:
253+
* p1:
254+
* type: string
255+
* Cat:
256+
* allOf:
257+
* - $ref: '#/components/schemas/Pet'
258+
* - type: object
259+
* properties:
260+
* p2:
261+
* type: string
262+
*
263+
* @return the discriminator.
264+
*/
223265
public CodegenDiscriminator getDiscriminator() {
224266
return discriminator;
225267
}
@@ -228,6 +270,14 @@ public void setDiscriminator(CodegenDiscriminator discriminator) {
228270
this.discriminator = discriminator;
229271
}
230272

273+
/**
274+
* Returns the name of the discriminator property for this schema in the OpenAPI document.
275+
* In the OpenAPI document, the discriminator may be specified in the local schema or
276+
* it may be inherited, such as through a 'allOf' schema which references another schema
277+
* that has a discriminator, recursively.
278+
*
279+
* @return the name of the discriminator property.
280+
*/
231281
public String getDiscriminatorName() {
232282
return discriminator == null ? null : discriminator.getPropertyName();
233283
}

modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2553,36 +2553,60 @@ private Discriminator recursiveGetDiscriminator(Schema sc, OpenAPI openAPI) {
25532553
if (composedSchema.getOneOf() != null && composedSchema.getOneOf().size() != 0) {
25542554
// All oneOf definitions must contain the discriminator
25552555
Integer hasDiscriminatorCnt = 0;
2556+
Integer hasNullTypeCnt = 0;
25562557
Set<String> discriminatorsPropNames = new HashSet<>();
2557-
for (Schema oneOf : composedSchema.getOneOf()) {
2558+
for (Schema oneOf: composedSchema.getOneOf()) {
2559+
if (ModelUtils.isNullType(oneOf)) {
2560+
// The null type does not have a discriminator. Skip.
2561+
hasNullTypeCnt++;
2562+
continue;
2563+
}
25582564
foundDisc = recursiveGetDiscriminator(oneOf, openAPI);
25592565
if (foundDisc != null) {
25602566
discriminatorsPropNames.add(foundDisc.getPropertyName());
25612567
hasDiscriminatorCnt++;
25622568
}
25632569
}
2564-
if (hasDiscriminatorCnt == composedSchema.getOneOf().size() && discriminatorsPropNames.size() == 1) {
2570+
if (discriminatorsPropNames.size() > 1) {
2571+
throw new RuntimeException("The oneOf schemas have conflicting discriminator property names. " +
2572+
"oneOf schemas must have the same property name, but found " + String.join(", ", discriminatorsPropNames));
2573+
}
2574+
if ((hasDiscriminatorCnt + hasNullTypeCnt) == composedSchema.getOneOf().size() && discriminatorsPropNames.size() == 1) {
25652575
disc.setPropertyName(foundDisc.getPropertyName());
25662576
disc.setMapping(foundDisc.getMapping());
25672577
return disc;
25682578
}
2579+
// If the scenario when oneOf has two children and one of them is the 'null' type,
2580+
// there is no need for a discriminator.
25692581
}
25702582
if (composedSchema.getAnyOf() != null && composedSchema.getAnyOf().size() != 0) {
25712583
// All anyOf definitions must contain the discriminator because a min of one must be selected
25722584
Integer hasDiscriminatorCnt = 0;
2585+
Integer hasNullTypeCnt = 0;
25732586
Set<String> discriminatorsPropNames = new HashSet<>();
2574-
for (Schema anyOf : composedSchema.getAnyOf()) {
2587+
for (Schema anyOf: composedSchema.getAnyOf()) {
2588+
if (ModelUtils.isNullType(anyOf)) {
2589+
// The null type does not have a discriminator. Skip.
2590+
hasNullTypeCnt++;
2591+
continue;
2592+
}
25752593
foundDisc = recursiveGetDiscriminator(anyOf, openAPI);
25762594
if (foundDisc != null) {
25772595
discriminatorsPropNames.add(foundDisc.getPropertyName());
25782596
hasDiscriminatorCnt++;
25792597
}
25802598
}
2581-
if (hasDiscriminatorCnt == composedSchema.getAnyOf().size() && discriminatorsPropNames.size() == 1) {
2599+
if (discriminatorsPropNames.size() > 1) {
2600+
throw new RuntimeException("The anyOf schemas have conflicting discriminator property names. " +
2601+
"anyOf schemas must have the same property name, but found " + String.join(", ", discriminatorsPropNames));
2602+
}
2603+
if ((hasDiscriminatorCnt + hasNullTypeCnt) == composedSchema.getAnyOf().size() && discriminatorsPropNames.size() == 1) {
25822604
disc.setPropertyName(foundDisc.getPropertyName());
25832605
disc.setMapping(foundDisc.getMapping());
25842606
return disc;
25852607
}
2608+
// If the scenario when anyOf has two children and one of them is the 'null' type,
2609+
// there is no need for a discriminator.
25862610
}
25872611
}
25882612
return null;
@@ -2611,7 +2635,10 @@ protected List<MappedModel> getOneOfAnyOfDescendants(String composedSchemaName,
26112635
if (schemaList == null) {
26122636
continue;
26132637
}
2614-
for (Schema sc : schemaList) {
2638+
for (Schema sc: schemaList) {
2639+
if (ModelUtils.isNullType(sc)) {
2640+
continue;
2641+
}
26152642
String ref = sc.get$ref();
26162643
if (ref == null) {
26172644
// for schemas with no ref, it is not possible to build the discriminator map

modules/openapi-generator/src/main/java/org/openapitools/codegen/validations/oas/OpenApiSchemaValidations.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ class OpenApiSchemaValidations extends GenericValidator<SchemaWrapper> {
6161
* <p>
6262
* Where the only examples of oneOf in OpenAPI Specification are used to define either/or type structures rather than validations.
6363
* Because of this ambiguity in the spec about what is non-standard about oneOf support, we'll warn as a recommendation that
64-
ne * properties on the schema defining oneOf relationships may not be intentional in the OpenAPI Specification.
64+
* properties on the schema defining oneOf relationships may not be intentional in the OpenAPI Specification.
6565
*
6666
* @param schemaWrapper An input schema, regardless of the type of schema
6767
* @return {@link ValidationRule.Pass} if the check succeeds, otherwise {@link ValidationRule.Fail}

modules/openapi-generator/src/main/resources/python/python-experimental/model_templates/method_discriminator.mustache

Lines changed: 0 additions & 12 deletions
This file was deleted.

modules/openapi-generator/src/main/resources/python/python-experimental/model_templates/model_composed.mustache

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,4 @@ class {{unescapedDescription}}(ModelComposed):
5151
{{{.}}},
5252
{{/oneOf}}
5353
],
54-
}{{#discriminator}}
55-
56-
{{> python-experimental/model_templates/method_discriminator }}{{/discriminator}}
54+
}

modules/openapi-generator/src/main/resources/python/python-experimental/model_templates/model_normal.mustache

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,4 @@ class {{unescapedDescription}}(ModelNormal):
2626

2727
_composed_schemas = {}
2828

29-
{{> python-experimental/model_templates/method_init_normal}}{{#discriminator}}
30-
31-
{{> python-experimental/model_templates/method_discriminator }}{{/discriminator}}
29+
{{> python-experimental/model_templates/method_init_normal}}

modules/openapi-generator/src/main/resources/python/python-experimental/model_utils.mustache

Lines changed: 132 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -66,31 +66,78 @@ class OpenApiModel(object):
6666
# pick a new schema/class to instantiate because a discriminator
6767
# propertyName value was passed in
6868

69+
# Build a list containing all oneOf and anyOf descendants.
70+
oneof_anyof_classes = None
71+
if cls._composed_schemas is not None:
72+
oneof_anyof_classes = (
73+
cls._composed_schemas.get('oneOf', ()) +
74+
cls._composed_schemas.get('anyOf', ()))
75+
if (oneof_anyof_classes and none_type in oneof_anyof_classes and
76+
len(args) == 1 and args[0] is None):
77+
# The input data is the 'null' value AND one of the oneOf/anyOf children
78+
# is the 'null' type (which is introduced in OAS schema >= 3.1).
79+
return None
80+
6981
visited_composed_classes = kwargs.get('_visited_composed_classes', ())
7082
if (
7183
cls.discriminator is None or
7284
cls in visited_composed_classes
7385
):
74-
# we don't have a discriminator
75-
# or we have already visited this class before and are sure that we
76-
# want to instantiate it this time
86+
# This openapi schema (cls) does not have a discriminator
87+
# Or we have already visited this class before and are sure that we
88+
# want to instantiate it this time.
89+
#
90+
# If we are making an instance of a composed schema Descendent
91+
# which allOf includes Ancestor, then Ancestor contains
92+
# a discriminator that includes Descendent.
93+
# So if we make an instance of Descendent, we have to make an
94+
# instance of Ancestor to hold the allOf properties.
95+
# This code detects that use case and makes the instance of Ancestor
96+
# For example:
97+
# When making an instance of Dog, _visited_composed_classes = (Dog,)
98+
# then we make an instance of Animal to include in dog._composed_instances
99+
# so when we are here, cls is Animal
100+
# cls.discriminator != None
101+
# cls not in _visited_composed_classes
102+
# new_cls = Dog
103+
# but we know we know that we already have Dog
104+
# because it is in visited_composed_classes
105+
# so make Animal here
77106
return super(OpenApiModel, cls).__new__(cls)
78107

79-
oneof_anyof_classes = []
80-
oneof_anyof_classes.extend(cls._composed_schemas.get('oneOf', ()))
81-
oneof_anyof_classes.extend(cls._composed_schemas.get('anyOf', ()))
82-
new_cls = cls.get_discriminator_class(kwargs)
108+
# Get the name and value of the discriminator property.
109+
# The discriminator name is obtained from the discriminator meta-data
110+
# and the discriminator value is obtained from the input data.
111+
discr_propertyname_py = list(cls.discriminator.keys())[0]
112+
discr_propertyname_js = cls.attribute_map[discr_propertyname_py]
113+
if discr_propertyname_js in kwargs:
114+
discr_value = kwargs[discr_propertyname_js]
115+
elif discr_propertyname_py in kwargs:
116+
discr_value = kwargs[discr_propertyname_py]
117+
else:
118+
# The input data does not contain the discriminator property.
119+
path_to_item = kwargs.get('_path_to_item', ())
120+
raise ApiValueError(
121+
"Cannot deserialize input data due to missing discriminator. "
122+
"The discriminator property '%s' is missing at path: %s" %
123+
(discr_propertyname_js, path_to_item)
124+
)
125+
126+
# Implementation note: the last argument to get_discriminator_class
127+
# is a list of visited classes. get_discriminator_class may recursively
128+
# call itself and update the list of visited classes, and the initial
129+
# value must be an empty list. Hence not using 'visited_composed_classes'
130+
new_cls = get_discriminator_class(
131+
cls, discr_propertyname_py, discr_value, [])
83132
if new_cls is None:
84-
disc_prop_name_py = list(cls.discriminator.keys())[0]
85-
disc_prop_name_js = cls.attribute_map[disc_prop_name_py]
86133
path_to_item = kwargs.get('_path_to_item', ())
87134
disc_prop_value = kwargs.get(
88-
disc_prop_name_js, kwargs.get(disc_prop_name_py))
135+
discr_propertyname_js, kwargs.get(discr_propertyname_py))
89136
raise ApiValueError(
90137
"Cannot deserialize input data due to invalid discriminator "
91138
"value. The OpenAPI document has no mapping for discriminator "
92139
"property '%s'='%s' at path: %s" %
93-
(disc_prop_name_js, disc_prop_value, path_to_item)
140+
(discr_propertyname_js, disc_prop_value, path_to_item)
94141
)
95142

96143
if new_cls in visited_composed_classes:
@@ -101,7 +148,7 @@ class OpenApiModel(object):
101148
kwargs['_visited_composed_classes'] = visited_composed_classes + (cls,)
102149

103150
if cls._composed_schemas.get('allOf') and oneof_anyof_child:
104-
# validate that we can make self because when we make the
151+
# Validate that we can make self because when we make the
105152
# new_cls it will not include the allOf validations in self
106153
self_inst = super(OpenApiModel, cls).__new__(cls)
107154
self_inst.__init__(*args, **kwargs)
@@ -131,7 +178,29 @@ class ModelNormal(OpenApiModel):
131178

132179
class ModelComposed(OpenApiModel):
133180
"""the parent class of models whose type == object in their
134-
swagger/openapi and have oneOf/allOf/anyOf"""
181+
swagger/openapi and have oneOf/allOf/anyOf
182+
183+
When one sets a property we use var_name_to_model_instances to store the value in
184+
the correct class instances + run any type checking + validation code.
185+
When one gets a property we use var_name_to_model_instances to get the value
186+
from the correct class instances.
187+
This allows multiple composed schemas to contain the same property with additive
188+
constraints on the value.
189+
190+
_composed_schemas (dict) stores the anyOf/allOf/oneOf classes
191+
key (str): allOf/oneOf/anyOf
192+
value (list): the classes in the XOf definition.
193+
Note: none_type can be included when the openapi document version >= 3.1.0
194+
_composed_instances (list): stores a list of instances of the composed schemas
195+
defined in _composed_schemas. When properties are accessed in the self instance,
196+
they are returned from the self._data_store or the data stores in the instances
197+
in self._composed_schemas
198+
_var_name_to_model_instances (dict): maps between a variable name on self and
199+
the composed instances (self included) which contain that data
200+
key (str): property name
201+
value (list): list of class instances, self or instances in _composed_instances
202+
which contain the value that the key is referring to.
203+
"""
135204

136205
{{> python-experimental/model_templates/methods_setattr_getattr_composed }}
137206

@@ -624,6 +693,56 @@ def deserialize_primitive(data, klass, path_to_item):
624693
)
625694

626695

696+
def get_discriminator_class(model_class,
697+
discr_name,
698+
discr_value, cls_visited):
699+
"""Returns the child class specified by the discriminator.
700+
701+
Args:
702+
model_class (OpenApiModel): the model class.
703+
discr_name (string): the name of the discriminator property.
704+
discr_value (any): the discriminator value.
705+
cls_visited (list): list of model classes that have been visited.
706+
Used to determine the discriminator class without
707+
visiting circular references indefinitely.
708+
709+
Returns:
710+
used_model_class (class/None): the chosen child class that will be used
711+
to deserialize the data, for example dog.Dog.
712+
If a class is not found, None is returned.
713+
"""
714+
715+
if model_class in cls_visited:
716+
# The class has already been visited and no suitable class was found.
717+
return None
718+
cls_visited.append(model_class)
719+
used_model_class = None
720+
if discr_name in model_class.discriminator:
721+
class_name_to_discr_class = model_class.discriminator[discr_name]
722+
used_model_class = class_name_to_discr_class.get(discr_value)
723+
if used_model_class is None:
724+
# We didn't find a discriminated class in class_name_to_discr_class.
725+
# The discriminator mapping may exist in a descendant (anyOf, oneOf)
726+
# or ancestor (allOf).
727+
# Ancestor example: in the "Dog -> Mammal -> Chordate -> Animal"
728+
# hierarchy, the discriminator mappings may be defined at any level
729+
# in the hieararchy.
730+
# Descendant example: a schema is oneOf[Plant, Mammal], and each
731+
# oneOf child may itself be an allOf with some arbitrary hierarchy,
732+
# and a graph traversal is required to find the discriminator.
733+
composed_children = model_class._composed_schemas.get('oneOf', ()) + \
734+
model_class._composed_schemas.get('anyOf', ()) + \
735+
model_class._composed_schemas.get('allOf', ())
736+
for cls in composed_children:
737+
# Check if the schema has inherited discriminators.
738+
if cls.discriminator is not None:
739+
used_model_class = get_discriminator_class(
740+
cls, discr_name, discr_value, cls_visited)
741+
if used_model_class is not None:
742+
return used_model_class
743+
return used_model_class
744+
745+
627746
def deserialize_model(model_data, model_class, path_to_item, check_type,
628747
configuration, from_server):
629748
"""Deserializes model_data to model instance.

0 commit comments

Comments
 (0)