fix(openapi): openapi 3.1.0 - use oneOf to mark nullable primitive props as well (#14103)

This commit is contained in:
Kevin Chun 2025-07-16 07:24:30 -07:00 committed by GitHub
parent 5471be6d4d
commit e7f45b1b49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 190 additions and 56 deletions

View File

@ -1108,7 +1108,7 @@ public class OpenAPIV3Generator {
// Set enums to "string". // Set enums to "string".
if (s.getEnum() != null && !s.getEnum().isEmpty()) { if (s.getEnum() != null && !s.getEnum().isEmpty()) {
if (s.getNullable() != null && s.getNullable()) { if (s.getNullable() != null && s.getNullable()) {
nullableSchema(s, TYPE_STRING_NULLABLE); s = toNullablePrimitive(s, TYPE_STRING);
} else { } else {
s.setType(TYPE_STRING); s.setType(TYPE_STRING);
} }
@ -1119,53 +1119,58 @@ public class OpenAPIV3Generator {
.orElse(new HashSet()); .orElse(new HashSet());
Map<String, Schema> properties = Map<String, Schema> properties =
Optional.ofNullable(s.getProperties()).orElse(new HashMap<>()); Optional.ofNullable(s.getProperties()).orElseGet(HashMap::new);
properties.forEach( for (Map.Entry<String, Schema> entry : properties.entrySet()) {
(name, schema) -> { String name = entry.getKey();
schema.specVersion(SpecVersion.V31); Schema<?> prop = entry.getValue();
String $ref = schema.get$ref(); prop.specVersion(SpecVersion.V31);
boolean isNameRequired = requiredNames.contains(name); String $ref = prop.get$ref();
boolean isRequired = requiredNames.contains(name);
if (definitions.has(n)) { // ---- default means "not required" -----------------------------------
JsonNode field = definitions.get(n); if (definitions.has(n)) {
boolean hasDefault = JsonNode field = definitions.get(n);
field.get("properties").get(name).has("default") boolean hasDefault =
&& !field.get("properties").get(name).get("default").isNull(); field.get("properties").get(name).has("default")
if (hasDefault) { && !field.get("properties").get(name).get("default").isNull();
// A default value means it is not required, regardless of nullability if (hasDefault) {
s.getRequired().remove(name); s.getRequired().remove(name);
if (s.getRequired().isEmpty()) { if (s.getRequired().isEmpty()) {
s.setRequired(null); s.setRequired(null);
}
}
} }
}
}
if ($ref != null && !isNameRequired) { // ---- optional $ref oneOf($ref, null) -----------------------------
// A non-required $ref property must be wrapped in a { oneOf: [ $ref, if ($ref != null && !isRequired) {
// null ] } prop.setType(null);
// object to allow the prop.set$ref(null);
// property to be marked as nullable prop.setOneOf(List.of(newSchema().$ref($ref), newSchema().type(TYPE_NULL)));
schema.setType(null); continue;
schema.set$ref(null); }
schema.setOneOf(
List.of(newSchema().$ref($ref), newSchema().type(TYPE_NULL)));
}
if ($ref == null) { // --------------------------------------------------------------------
if (schema.getEnum() != null && !schema.getEnum().isEmpty()) { // optional ENUM / primitive -> ["string","null"]
if ((schema.getNullable() != null && schema.getNullable()) // required ENUM / primitive -> "type": "string"
|| !isNameRequired) { // --------------------------------------------------------------------
nullableSchema(schema, TYPE_STRING_NULLABLE); if ($ref == null) {
} else { boolean isEnum = prop.getEnum() != null && !prop.getEnum().isEmpty();
schema.setType(TYPE_STRING); if (!isRequired) {
} // OPTIONAL --> allow explicit null
} else if (schema.getEnum() == null && !isNameRequired) { String scalar = isEnum ? TYPE_STRING : prop.getType();
nullableSchema(schema, Set.of(schema.getType(), TYPE_NULL)); Schema<?> nullable = toNullablePrimitive(prop, scalar);
} entry.setValue(nullable);
} } else {
}); // REQUIRED --> keep scalar type, ensure it's present
prop.setType(isEnum ? TYPE_STRING : prop.getType());
prop.setTypes(null);
}
}
}
} }
// Clear out the previous state
s.setJsonSchema(null);
components.addSchemas(n, s); components.addSchemas(n, s);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
@ -2022,16 +2027,143 @@ public class OpenAPIV3Generator {
return new Schema().specVersion(SPEC_VERSION); return new Schema().specVersion(SPEC_VERSION);
} }
private static Schema nullableSchema(Schema origin, Set<String> types) { /**
if (origin == null) { * Replace a StringSchema / IntegerSchema / with a base Schema that has oneOf: [<scalar>, null]
return newSchema().types(types); * while preserving all other attributes (format, description, examples, ). This is because
* StringSchema / IntegerSchema / etc will hard-code the type attributes which breaks OAS 3.1.0
* nullability for primitives.
*
* @param original the original typed schema (StringSchema, IntegerSchema, etc.)
* @param scalarType the scalar type string (e.g., "string", "integer", "number", "boolean",
* "object")
* @return a new Schema with proper OAS 3.1.0 nullable oneOf structure
* @throws IllegalArgumentException if original schema or scalarType is null/empty
*/
private static <T> Schema<T> toNullablePrimitive(Schema<T> original, String scalarType) {
if (original == null) {
throw new IllegalArgumentException("Original schema cannot be null");
}
if (scalarType == null || scalarType.trim().isEmpty()) {
throw new IllegalArgumentException("Scalar type cannot be null or empty");
} }
String nonNullType = types.stream().filter(t -> !"null".equals(t)).findFirst().orElse(null); try {
// Create a deep clone without modifying the original
Schema<T> clone = Json.mapper().convertValue(original, Schema.class);
origin.setType(nonNullType); // Create scalar schema and transfer ALL type-specific properties
origin.setTypes(types); Schema<T> scalarSchema = new Schema<>();
scalarSchema.setType(scalarType.trim());
scalarSchema.setSpecVersion(SPEC_VERSION);
return origin; // Transfer type-specific properties to scalar schema
// String-specific properties
if (clone.getFormat() != null) {
scalarSchema.setFormat(clone.getFormat());
clone.setFormat(null);
}
if (clone.getMinLength() != null) {
scalarSchema.setMinLength(clone.getMinLength());
clone.setMinLength(null);
}
if (clone.getMaxLength() != null) {
scalarSchema.setMaxLength(clone.getMaxLength());
clone.setMaxLength(null);
}
if (clone.getPattern() != null) {
scalarSchema.setPattern(clone.getPattern());
clone.setPattern(null);
}
// Number-specific properties
if (clone.getMinimum() != null) {
scalarSchema.setMinimum(clone.getMinimum());
clone.setMinimum(null);
}
if (clone.getMaximum() != null) {
scalarSchema.setMaximum(clone.getMaximum());
clone.setMaximum(null);
}
if (clone.getExclusiveMinimum() != null) {
scalarSchema.setExclusiveMinimum(clone.getExclusiveMinimum());
clone.setExclusiveMinimum(null);
}
if (clone.getExclusiveMaximum() != null) {
scalarSchema.setExclusiveMaximum(clone.getExclusiveMaximum());
clone.setExclusiveMaximum(null);
}
if (clone.getMultipleOf() != null) {
scalarSchema.setMultipleOf(clone.getMultipleOf());
clone.setMultipleOf(null);
}
// Object-specific properties
if (clone.getProperties() != null) {
scalarSchema.setProperties(clone.getProperties());
clone.setProperties(null);
}
if (clone.getAdditionalProperties() != null) {
scalarSchema.setAdditionalProperties(clone.getAdditionalProperties());
clone.setAdditionalProperties(null);
}
if (clone.getRequired() != null) {
scalarSchema.setRequired(clone.getRequired());
clone.setRequired(null);
}
if (clone.getMinProperties() != null) {
scalarSchema.setMinProperties(clone.getMinProperties());
clone.setMinProperties(null);
}
if (clone.getMaxProperties() != null) {
scalarSchema.setMaxProperties(clone.getMaxProperties());
clone.setMaxProperties(null);
}
// Array-specific properties
if (clone.getItems() != null) {
scalarSchema.setItems(clone.getItems());
clone.setItems(null);
}
if (clone.getMinItems() != null) {
scalarSchema.setMinItems(clone.getMinItems());
clone.setMinItems(null);
}
if (clone.getMaxItems() != null) {
scalarSchema.setMaxItems(clone.getMaxItems());
clone.setMaxItems(null);
}
if (clone.getUniqueItems() != null) {
scalarSchema.setUniqueItems(clone.getUniqueItems());
clone.setUniqueItems(null);
}
// Enum (applies to any type)
if (clone.getEnum() != null) {
scalarSchema.setEnum(clone.getEnum());
clone.setEnum(null);
}
// Clear conflicting type information from parent
clone.setType(null);
clone.setNullable(null);
clone.setTypes(null);
// Create null schema
Schema<?> nullSchema = new Schema<>();
nullSchema.setType(TYPE_NULL);
nullSchema.setSpecVersion(SPEC_VERSION);
// Set oneOf on parent schema
clone.setOneOf(List.of(scalarSchema, nullSchema));
clone.setSpecVersion(SPEC_VERSION);
return clone;
} catch (Exception e) {
throw new RuntimeException(
String.format(
"Failed to convert schema to nullable primitive with type '%s'", scalarType),
e);
}
} }
} }

View File

@ -233,16 +233,18 @@ public class OpenAPIV3GeneratorTest {
Schema customProperties = properties.get("customProperties"); Schema customProperties = properties.get("customProperties");
assertNull(datasetPropertiesSchema.getRequired()); // not required due to defaults assertNull(datasetPropertiesSchema.getRequired()); // not required due to defaults
assertNull(customProperties.getNullable()); assertNull(customProperties.getNullable());
assertEquals(customProperties.getType(), "object");
assertEquals( assertEquals(
customProperties.getTypes(), customProperties.getType(),
Set.of("object")); // it is however still not optional, therefore null is not allowed "object"); // it is however still not optional, therefore null is not allowed
// Assert non-required properties are nullable // Assert non-required properties are nullable
Schema name = properties.get("name"); Schema name = properties.get("name");
assertNull(name.getNullable()); assertNull(name.getNullable());
assertEquals(name.getType(), "string"); assertNull(name.getType());
assertEquals(name.getTypes(), Set.of("string", "null")); assertNull(name.getTypes());
assertEquals(name.getOneOf().size(), 2);
assertTrue(name.getOneOf().stream().anyMatch(s -> "string".equals(((Schema<?>) s).getType())));
assertTrue(name.getOneOf().stream().anyMatch(s -> "null".equals(((Schema<?>) s).getType())));
// Assert non-required $ref properties are replaced by nullable { anyOf: [ $ref ] } objects // Assert non-required $ref properties are replaced by nullable { anyOf: [ $ref ] } objects
Schema created = properties.get("created"); Schema created = properties.get("created");
@ -297,8 +299,8 @@ public class OpenAPIV3GeneratorTest {
Schema titleSchema = properties.get("title"); Schema titleSchema = properties.get("title");
assertNull(titleSchema.getNullable()); assertNull(titleSchema.getNullable());
assertEquals(titleSchema.getTypes(), Set.of("string")); // null is not allowed assertEquals(titleSchema.getType(), "string"); // null is not allowed
assertEquals(titleSchema.getType(), "string"); assertNull(titleSchema.getTypes());
Schema changeAuditStampsSchema = properties.get("changeAuditStamps"); Schema changeAuditStampsSchema = properties.get("changeAuditStamps");
assertNull(changeAuditStampsSchema.getNullable()); assertNull(changeAuditStampsSchema.getNullable());