Skip to content

Commit 0b1ac88

Browse files
authored
Improve OneOf handling with new normalizer REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING (#23543)
* normalizer REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING * Work in progress * Improvements * Merge master * Fix invalid path * Improve assertions * Fix invalid discriminator value * filename case * filename case * Rollback composed-oneof.yaml * Improve normalization * Fix building of allOf * Fix hasParent * Fix some cubic findings * Fix some cubic findings * Fix infinite recursion stopping too early * Force build * Use getReferencedSchema in search for properties * Improve hasParent -> isParentReferencedInChild * Cubic suggestions * Add assertions for JsonSubTypes.Types * Clean moved child * Convert a ComposedSchema to a Schema if there is no oneOf/allOf/anyOf -> avoid side effects with bugs in InlineModelResolver * Undo changes in decompose Schema * Inline model resolver for issue 22209 * Undo formatting in ModelUtils * Merge master
1 parent 568501f commit 0b1ac88

18 files changed

Lines changed: 1130 additions & 38 deletions

docs/customization.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,13 @@ Example:
651651
java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g java -i modules/openapi-generator/src/test/resources/3_0/required-properties.yaml -o /tmp/java-okhttp/ --openapi-normalizer REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT=true
652652
```
653653
654+
- `REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING`: when set to true, oneOf is removed and is converted into mappings in a discriminator mapping.
655+
656+
Example:
657+
```
658+
java -jar modules/openapi-generator-cli/target/openapi-generator-cli.jar generate -g spring -i modules/openapi-generator/src/test/resources/3_0/spring/issue_23527.yaml -o /tmp/java-spring/ --openapi-normalizer REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING=true
659+
```
660+
654661
- `FILTER`
655662
656663
The `FILTER` parameter allows selective inclusion of API operations based on specific criteria. It applies the `x-internal: true` property to operations that do **not** match the specified values, preventing them from being generated. Multiple filters can be separated by a semicolon.

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,10 @@ private void flattenComponents() {
694694
} else if (ModelUtils.isOneOf(model)) { // contains oneOf only
695695
gatherInlineModels(model, modelName);
696696
} else if (ModelUtils.isComposedSchema(model)) {
697+
// composed Schema can have properties!
698+
if (ModelUtils.hasProperties(model)) {
699+
gatherInlineModels(model, modelName);
700+
}
697701
// inline child schemas
698702
flattenComposedChildren(modelName + "_allOf", model.getAllOf(), !Boolean.TRUE.equals(this.refactorAllOfInlineSchemas));
699703
flattenComposedChildren(modelName + "_anyOf", model.getAnyOf(), false);

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

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ public class OpenAPINormalizer {
7878
// are removed as most generators cannot handle such case at the moment
7979
final String REMOVE_ANYOF_ONEOF_AND_KEEP_PROPERTIES_ONLY = "REMOVE_ANYOF_ONEOF_AND_KEEP_PROPERTIES_ONLY";
8080

81+
// when set to true, oneOf is removed and is converted into mappings in a discriminator mapping
82+
final String REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING = "REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING";
83+
84+
8185
// when set to true, oneOf/anyOf with either string or enum string as sub schemas will be simplified
8286
// to just string
8387
final String SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING = "SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING";
@@ -214,6 +218,7 @@ public OpenAPINormalizer(OpenAPI openAPI, Map<String, String> inputRules) {
214218
ruleNames.add(SIMPLIFY_ONEOF_ANYOF_ENUM);
215219
ruleNames.add(REMOVE_PROPERTIES_FROM_TYPE_OTHER_THAN_OBJECT);
216220
ruleNames.add(SORT_MODEL_PROPERTIES);
221+
ruleNames.add(REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING);
217222

218223
// rules that are default to true
219224
rules.put(SIMPLIFY_ONEOF_ANYOF, true);
@@ -639,6 +644,10 @@ protected void normalizeComponentsSchemas() {
639644

640645
// normalize the schemas
641646
schemas.put(schemaName, normalizeSchema(schema, new HashSet<>()));
647+
648+
if (getRule(REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING)) {
649+
ensureInheritanceForDiscriminatorMappings(schema, schemaName);
650+
}
642651
}
643652
}
644653
}
@@ -1069,6 +1078,7 @@ protected Schema normalizeOneOf(Schema schema, Set<Schema> visitedSchemas) {
10691078
// update sub-schema with the updated schema
10701079
schema.getOneOf().set(i, normalizeSchema((Schema) item, visitedSchemas));
10711080
}
1081+
schema = processReplaceOneOfByMapping(schema);
10721082
} else {
10731083
// normalize it as it's no longer an oneOf
10741084
schema = normalizeSchema(schema, visitedSchemas);
@@ -1564,6 +1574,245 @@ protected Schema processSimplifyOneOf(Schema schema) {
15641574
return schema;
15651575
}
15661576

1577+
1578+
/**
1579+
* Ensure inheritance is correctly defined for OneOf and Discriminators.
1580+
*
1581+
* For schemas containing oneOf and discriminator.propertyName:
1582+
* <ul>
1583+
* <li>Create the mappings as $refs</li>
1584+
* <li>Remove OneOf</li>
1585+
* </ul>
1586+
*/
1587+
protected Schema processReplaceOneOfByMapping(Schema schema) {
1588+
if (!getRule(REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING) || schema.getOneOf() == null) {
1589+
return schema;
1590+
}
1591+
Discriminator discriminator = schema.getDiscriminator();
1592+
if (discriminator != null) {
1593+
boolean inlineSchema = isInlineSchema(schema);
1594+
if (inlineSchema) {
1595+
// the For referenced schemas, ensure that there is an allOf with this schema.
1596+
LOGGER.warn("Inline oneOf schema not supported by REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING normalization");
1597+
return schema;
1598+
}
1599+
if (discriminator.getMapping() == null && discriminator.getPropertyName() != null) {
1600+
List<Schema> oneOfs = schema.getOneOf();
1601+
if (oneOfs.stream().anyMatch(oneOf -> oneOf.get$ref() == null)) {
1602+
LOGGER.warn("oneOf should only contain $ref for REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING normalization");
1603+
return schema;
1604+
}
1605+
Map<String, String> mappings = new TreeMap<>();
1606+
// is the discriminator qttribute qlready in this schema?
1607+
// if yes, it will be deleted in references oneOf to avoid duplicates
1608+
boolean hasProperty = findProperty(schema, discriminator.getPropertyName(), false, new HashSet<>()) != null;
1609+
discriminator.setMapping(mappings);
1610+
for (Schema oneOf : oneOfs) {
1611+
String refSchema = oneOf.get$ref();
1612+
String name = getDiscriminatorValue(refSchema, discriminator.getPropertyName(), hasProperty, new HashSet<>(List.of(schema)));
1613+
mappings.put(name, refSchema);
1614+
1615+
}
1616+
// remove oneOf and only keep the new discriminator mapping
1617+
schema.oneOf(null);
1618+
} else if (discriminator.getPropertyName() == null) {
1619+
LOGGER.warn("Missing property name in discriminator");
1620+
} else if (discriminator.getMapping() != null && discriminator.getMapping().size() != schema.getOneOf().size()) {
1621+
LOGGER.warn("Discriminator mapping size " + discriminator.getMapping().size() + " mismatch with oneOf size " + schema.getOneOf().size());
1622+
} else {
1623+
// remove oneOf and only keep the discriminator mapping
1624+
LOGGER.info("Removing oneOf, discriminator mapping takes precedences on OneOfs");
1625+
schema.oneOf(null);
1626+
}
1627+
}
1628+
1629+
return schema;
1630+
}
1631+
1632+
private boolean isInlineSchema(Schema schema) {
1633+
if (openAPI.getComponents()!=null && openAPI.getComponents().getSchemas()!=null) {
1634+
int identity = System.identityHashCode(schema);
1635+
for (Schema componentSchema: openAPI.getComponents().getSchemas().values()) {
1636+
if (System.identityHashCode(componentSchema) == identity) {
1637+
return false;
1638+
}
1639+
}
1640+
}
1641+
return true;
1642+
}
1643+
1644+
/**
1645+
* Best effort to retrieve a good discriminator value.
1646+
* By order of precedence:
1647+
* <ul>
1648+
* <li>x-discriminator-value</li>
1649+
* <li>single enum value for attribute used by the discriminator.propertyName</li>
1650+
* <li>hame of the schema</li>
1651+
* </ul>
1652+
*
1653+
* @param refSchema $ref value like #/components/schemas/Dog
1654+
* @param discriminatorPropertyName name of the property used in the discriminator mapping
1655+
* @param propertyAlreadyPresent if true, delete the property in the referenced schemas to avoid duplicates
1656+
*
1657+
* @return the name
1658+
*/
1659+
protected String getDiscriminatorValue(String refSchema, String discriminatorPropertyName, boolean propertyAlreadyPresent, Set<Schema> visitedSchemas) {
1660+
String schemaName = ModelUtils.getSimpleRef(refSchema);
1661+
Schema schema = ModelUtils.getSchema(openAPI, schemaName);
1662+
Schema property = findProperty(schema, discriminatorPropertyName, propertyAlreadyPresent, visitedSchemas);
1663+
if (schema != null && schema.getExtensions() != null) {
1664+
Object discriminatorValue = schema.getExtensions().get("x-discriminator-value");
1665+
if (discriminatorValue != null) {
1666+
return discriminatorValue.toString();
1667+
}
1668+
}
1669+
1670+
// find the discriminator value as a unique enum value
1671+
property = ModelUtils.getReferencedSchema(openAPI, property);
1672+
if (property != null) {
1673+
List enums = property.getEnum();
1674+
if (enums != null && enums.size() == 1) {
1675+
return enums.get(0).toString();
1676+
}
1677+
}
1678+
1679+
return schemaName;
1680+
}
1681+
1682+
/**
1683+
* find a property under the schema.
1684+
*
1685+
* @param schema
1686+
* @param propertyName property to find
1687+
* @param toDelete if true delete the found property
1688+
* @param visitedSchemas avoid infinite recursion
1689+
* @return found property or null if not found.
1690+
*/
1691+
private Schema findProperty(Schema schema, String propertyName, boolean toDelete, Set<Schema> visitedSchemas) {
1692+
schema = ModelUtils.getReferencedSchema(openAPI, schema);
1693+
if (propertyName == null || schema == null || visitedSchemas.contains(schema)) {
1694+
return null;
1695+
}
1696+
visitedSchemas.add(schema);
1697+
Map<String, Schema> properties = schema.getProperties();
1698+
if (properties != null) {
1699+
Schema property = ModelUtils.getReferencedSchema(openAPI, properties.get(propertyName));
1700+
if (property != null) {
1701+
if (toDelete) {
1702+
if (schema.getProperties().remove(propertyName) != null) {
1703+
LOGGER.info("property " + propertyName + " has been removed in REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING normalization");
1704+
if (schema.getProperties().isEmpty()) {
1705+
schema.setProperties(null);
1706+
}
1707+
}
1708+
}
1709+
return property;
1710+
}
1711+
}
1712+
List<Schema> allOfs = schema.getAllOf();
1713+
if (allOfs != null) {
1714+
for (Schema child : allOfs) {
1715+
Schema found = findProperty(child, propertyName, toDelete, visitedSchemas);
1716+
if (found != null) {
1717+
return found;
1718+
}
1719+
}
1720+
}
1721+
1722+
return null;
1723+
}
1724+
1725+
1726+
/**
1727+
* ensure that all schemas referenced in the discriminator mapping has an allOf to the parent schema.
1728+
*
1729+
* This allows DefaultCodeGen to detect inheritance.
1730+
*
1731+
* @param parent parent schma
1732+
* @param parentName name of the parent schema
1733+
*/
1734+
protected void ensureInheritanceForDiscriminatorMappings(Schema parent, String parentName) {
1735+
Discriminator discriminator = parent.getDiscriminator();
1736+
if (discriminator != null && discriminator.getMapping() != null) {
1737+
for (String mapping : discriminator.getMapping().values()) {
1738+
String refSchemaName = ModelUtils.getSimpleRef(mapping);
1739+
Schema child = ModelUtils.getSchema(openAPI, refSchemaName);
1740+
if (child != null) {
1741+
if (parentName != null) {
1742+
ensureInheritanceForDiscriminatorMapping(parent, child, parentName, new HashSet<>());
1743+
}
1744+
}
1745+
}
1746+
}
1747+
}
1748+
1749+
/**
1750+
* If not already present, add in the child an allOf referencing the parent.
1751+
*/
1752+
protected void ensureInheritanceForDiscriminatorMapping(Schema parent, Schema child, String parentName, Set<Schema> visitedSchemas) {
1753+
String reference = "#/components/schemas/" + parentName;
1754+
List<Schema> allOf = child.getAllOf();
1755+
if (allOf != null) {
1756+
if (isParentReferencedInChild(parent, child, reference, visitedSchemas)) {
1757+
// already done, so no need to add
1758+
return;
1759+
}
1760+
Schema refToParent = new Schema<>().$ref(reference);
1761+
allOf.add(refToParent);
1762+
} else {
1763+
allOf = new ArrayList<>();
1764+
child.setAllOf(allOf);
1765+
Schema refToParent = new Schema<>().$ref(reference);
1766+
allOf.add(refToParent);
1767+
Map<String, Schema> childProperties = child.getProperties();
1768+
if (childProperties != null) {
1769+
// move the properties inside the new allOf.
1770+
Schema newChildProperties = new Schema<>()
1771+
.properties(childProperties)
1772+
.additionalProperties(child.getAdditionalProperties());
1773+
ModelUtils.copyMetadata(child, newChildProperties);
1774+
allOf.add(newChildProperties);
1775+
child.properties(null)
1776+
.type(null)
1777+
.additionalProperties(null)
1778+
.description(null)
1779+
._default(null)
1780+
.deprecated(null)
1781+
.example(null)
1782+
.examples(null)
1783+
.readOnly(null)
1784+
.writeOnly(null)
1785+
.title(null);
1786+
}
1787+
}
1788+
}
1789+
1790+
/**
1791+
* return true if the child as an allOf referencing the parent schema.
1792+
*/
1793+
private boolean isParentReferencedInChild(Schema parent, Schema child, String reference, Set<Schema> visitedSchemas) {
1794+
if (child == null || visitedSchemas.contains(child)) {
1795+
return false;
1796+
}
1797+
if (child.get$ref() != null && child.get$ref().equals(reference)) {
1798+
return true;
1799+
}
1800+
child = ModelUtils.getReferencedSchema(openAPI, child);
1801+
if (visitedSchemas.contains(child)) {
1802+
return false;
1803+
}
1804+
visitedSchemas.add(child);
1805+
List<Schema> allOf = child.getAllOf();
1806+
if (allOf != null) {
1807+
for (Schema schema : allOf) {
1808+
if (isParentReferencedInChild(parent, schema, reference, visitedSchemas)) {
1809+
return true;
1810+
}
1811+
}
1812+
}
1813+
return false;
1814+
}
1815+
15671816
/**
15681817
* Set nullable to true in array/set if needed.
15691818
*

modules/openapi-generator/src/test/java/org/openapitools/codegen/OpenAPINormalizerTest.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
import org.openapitools.codegen.utils.ModelUtils;
2727
import org.testng.annotations.Test;
2828

29-
import java.lang.reflect.Array;
3029
import java.math.BigDecimal;
3130
import java.util.*;
3231

@@ -1502,4 +1501,47 @@ public Schema normalizeSchema(Schema schema, Set<Schema> visitedSchemas) {
15021501
}
15031502
}
15041503

1504+
@Test
1505+
public void testREPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING() {
1506+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_23527.yaml");
1507+
1508+
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
1509+
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, inputRules);
1510+
openAPINormalizer.normalize();
1511+
1512+
Schema geoJsonObject = openAPI.getComponents().getSchemas().get("GeoJsonObject");
1513+
Map<String, String> mapping = geoJsonObject.getDiscriminator().getMapping();
1514+
assertEquals(mapping, Map.of("MultiPolygon", "#/components/schemas/Multi-Polygon", "Polygon", "#/components/schemas/Polygon" ));
1515+
}
1516+
1517+
@Test
1518+
public void issue_14769() {
1519+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_14769.yaml");
1520+
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
1521+
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, inputRules);
1522+
openAPINormalizer.normalize();
1523+
// ModelUtils.dumpAsYaml(openAPI);
1524+
Schema vehicle = openAPI.getComponents().getSchemas().get("Vehicle");
1525+
Map<String, String> mapping = vehicle.getDiscriminator().getMapping();
1526+
assertEquals(mapping, Map.of("car", "#/components/schemas/Car", "plane", "#/components/schemas/Plane" ));
1527+
Schema car = openAPI.getComponents().getSchemas().get("Car");
1528+
assertNull(car.getProperties());
1529+
assertEquals(car.getAllOf().size(), 2);
1530+
assertEquals(((Schema)car.getAllOf().get(0)).get$ref(), "#/components/schemas/Vehicle");
1531+
assertEquals(((Schema)car.getAllOf().get(1)).getProperties().size(), 1);
1532+
assertEquals(((Schema)car.getAllOf().get(1)).getProperties().keySet(), Set.of("has_4_wheel_drive"));
1533+
}
1534+
1535+
@Test
1536+
public void oneOf_issue_23276() {
1537+
OpenAPI openAPI = TestUtils.parseSpec("src/test/resources/3_0/oneOf_issue_23276.yaml");
1538+
Map<String, String> inputRules = Map.of("REPLACE_ONE_OF_BY_DISCRIMINATOR_MAPPING", "true");
1539+
OpenAPINormalizer openAPINormalizer = new OpenAPINormalizer(openAPI, inputRules);
1540+
openAPINormalizer.normalize();
1541+
// ModelUtils.dumpAsYaml(openAPI);
1542+
Schema payload = (Schema)openAPI.getComponents().getSchemas().get("DeviceLifecycleEvent").getProperties().get("payload");
1543+
// inline oneOf are not converted
1544+
assertNotNull(payload.getOneOf());
1545+
}
1546+
15051547
}

0 commit comments

Comments
 (0)