fix(openapi): openapi 3.1.0 nullable refs to use oneOf(ref, null) (#14079)

This commit is contained in:
Kevin Chun 2025-07-14 18:33:11 -07:00 committed by GitHub
parent ff40a94908
commit c8457916c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 203 additions and 111 deletions

View File

@ -1187,13 +1187,18 @@ public class OpenAPIV3Generator {
NAME_SYSTEM_METADATA, NAME_SYSTEM_METADATA,
newSchema() newSchema()
.types(TYPE_OBJECT_NULLABLE) .types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata") .oneOf(
List.of(
newSchema().$ref(PATH_DEFINITIONS + "SystemMetadata"),
newSchema().type(TYPE_NULL)))
.description("System metadata for the aspect.")); .description("System metadata for the aspect."));
result.addProperty( result.addProperty(
NAME_AUDIT_STAMP, NAME_AUDIT_STAMP,
newSchema() newSchema()
.types(TYPE_OBJECT_NULLABLE) .types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "AuditStamp") .oneOf(
List.of(
newSchema().$ref(PATH_DEFINITIONS + "AuditStamp"), newSchema().type(TYPE_NULL)))
.description("Audit stamp for the aspect.")); .description("Audit stamp for the aspect."));
return result; return result;
} }
@ -1210,7 +1215,10 @@ public class OpenAPIV3Generator {
NAME_SYSTEM_METADATA, NAME_SYSTEM_METADATA,
newSchema() newSchema()
.types(TYPE_OBJECT_NULLABLE) .types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata") .oneOf(
List.of(
newSchema().$ref(PATH_DEFINITIONS + "SystemMetadata"),
newSchema().type(TYPE_NULL)))
.description("System metadata for the aspect.")); .description("System metadata for the aspect."));
Schema stringTypeSchema = newSchema(); Schema stringTypeSchema = newSchema();
@ -1359,8 +1367,8 @@ public class OpenAPIV3Generator {
.toList())) .toList()))
.properties( .properties(
Map.of( Map.of(
"entities", entitiesSchema, "entities", newSchema().oneOf(List.of(entitiesSchema, newSchema().type(TYPE_NULL))),
"aspects", aspectsSchema)); "aspects", newSchema().oneOf(List.of(aspectsSchema, newSchema().type(TYPE_NULL)))));
} }
private static Schema buildEntitiesPatchRequestSchema(List<EntitySpec> entitySpecs) { private static Schema buildEntitiesPatchRequestSchema(List<EntitySpec> entitySpecs) {
@ -1434,19 +1442,26 @@ public class OpenAPIV3Generator {
private static Schema buildEntityBatchGetRequestSchema( private static Schema buildEntityBatchGetRequestSchema(
final EntitySpec entity, Set<String> aspectNames) { final EntitySpec entity, Set<String> aspectNames) {
final Map<String, Schema> properties = Map<String, Schema> properties = new LinkedHashMap<>();
entity.getAspectSpecMap().entrySet().stream()
.filter(a -> aspectNames.contains(a.getValue().getName()))
.collect(
Collectors.toMap(
Map.Entry::getKey,
a -> newSchema().$ref("#/components/schemas/BatchGetRequestBody")));
properties.put( properties.put(
PROPERTY_URN, PROPERTY_URN,
newSchema().type(TYPE_STRING).description("Unique id for " + entity.getName())); newSchema().type(TYPE_STRING).description("Unique id for " + entity.getName()));
properties.put( entity.getAspectSpecMap().entrySet().stream()
entity.getKeyAspectName(), newSchema().$ref("#/components/schemas/BatchGetRequestBody")); .filter(
e ->
aspectNames.contains(e.getValue().getName())
|| e.getKey().equals(entity.getKeyAspectName()))
.forEach(
e ->
properties.put(
e.getKey(),
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.oneOf(
List.of(
newSchema().$ref("#/components/schemas/BatchGetRequestBody"),
newSchema().type(TYPE_NULL)))));
return newSchema() return newSchema()
.type(TYPE_OBJECT) .type(TYPE_OBJECT)
@ -1456,19 +1471,24 @@ public class OpenAPIV3Generator {
} }
private static Schema buildCrossEntityUpsertSchema(List<EntitySpec> entitySpecs) { private static Schema buildCrossEntityUpsertSchema(List<EntitySpec> entitySpecs) {
Map<String, Schema> props = new LinkedHashMap<>(); Map<String, Schema> props = new LinkedHashMap<>();
entitySpecs.forEach( entitySpecs.forEach(
e -> e -> {
props.put( Schema arraySchema =
e.getName(), newSchema()
newSchema() .type(TYPE_ARRAY)
.type(TYPE_ARRAY) .items(
.items( newSchema()
newSchema() .$ref(
.$ref( String.format(
String.format( "#/components/schemas/%s%s",
"#/components/schemas/%s%s", toUpperFirst(e.getName()), ENTITY_REQUEST_SUFFIX)));
toUpperFirst(e.getName()), ENTITY_REQUEST_SUFFIX))))); props.put(
e.getName(), newSchema().oneOf(List.of(arraySchema, newSchema().type(TYPE_NULL))));
});
return newSchema() return newSchema()
.type(TYPE_OBJECT) .type(TYPE_OBJECT)
.description("Mixed-entity upsert request body.") .description("Mixed-entity upsert request body.")
@ -1477,19 +1497,25 @@ public class OpenAPIV3Generator {
} }
private static Schema buildCrossEntityPatchSchema(List<EntitySpec> entitySpecs) { private static Schema buildCrossEntityPatchSchema(List<EntitySpec> entitySpecs) {
Map<String, Schema> props = new LinkedHashMap<>(); Map<String, Schema> props = new LinkedHashMap<>();
entitySpecs.forEach( entitySpecs.forEach(
e -> e -> {
props.put( Schema arraySchema =
e.getName(), newSchema()
newSchema() .type(TYPE_ARRAY)
.type(TYPE_ARRAY) .items(
.items( newSchema()
newSchema() .$ref(
.$ref( String.format(
String.format( "#/components/schemas/%s%s",
"#/components/schemas/%s%s", toUpperFirst(e.getName()), ENTITY_REQUEST_PATCH_SUFFIX)));
toUpperFirst(e.getName()), ENTITY_REQUEST_PATCH_SUFFIX)))));
props.put(
e.getName(), newSchema().oneOf(List.of(newSchema().type(TYPE_NULL), arraySchema)));
});
return newSchema() return newSchema()
.type(TYPE_OBJECT) .type(TYPE_OBJECT)
.description("Mixed-entity patch request body.") .description("Mixed-entity patch request body.")
@ -1499,18 +1525,23 @@ public class OpenAPIV3Generator {
private static Schema buildCrossEntityResponseSchema(List<EntitySpec> entitySpecs) { private static Schema buildCrossEntityResponseSchema(List<EntitySpec> entitySpecs) {
Map<String, Schema> props = new LinkedHashMap<>(); Map<String, Schema> props = new LinkedHashMap<>();
entitySpecs.forEach( entitySpecs.forEach(
e -> e -> {
props.put( Schema arraySchema =
e.getName(), newSchema()
newSchema() .type(TYPE_ARRAY)
.type(TYPE_ARRAY) .items(
.items( newSchema()
newSchema() .$ref(
.$ref( String.format(
String.format( "#/components/schemas/%s%s",
"#/components/schemas/%s%s", toUpperFirst(e.getName()), ENTITY_RESPONSE_SUFFIX)));
toUpperFirst(e.getName()), ENTITY_RESPONSE_SUFFIX)))));
props.put(
e.getName(), newSchema().oneOf(List.of(arraySchema, newSchema().type(TYPE_NULL))));
});
return newSchema() return newSchema()
.type(TYPE_OBJECT) .type(TYPE_OBJECT)
.description("Mixed-entity upsert / patch response.") .description("Mixed-entity upsert / patch response.")
@ -1519,21 +1550,24 @@ public class OpenAPIV3Generator {
} }
private static Schema buildCrossEntityBatchGetRequestSchema(List<EntitySpec> entitySpecs) { private static Schema buildCrossEntityBatchGetRequestSchema(List<EntitySpec> entitySpecs) {
Map<String, Schema> props = new LinkedHashMap<>(); Map<String, Schema> props = new LinkedHashMap<>();
entitySpecs.forEach( entitySpecs.forEach(
e -> e -> {
props.put( Schema arraySchema =
e.getName(), newSchema()
newSchema() .type(TYPE_ARRAY)
.type(TYPE_ARRAY) .items(
.items( newSchema()
newSchema() .$ref(
.$ref( String.format(
String.format( "#/components/schemas/%s%s",
"#/components/schemas/%s%s", "BatchGet" + toUpperFirst(e.getName()), ENTITY_REQUEST_SUFFIX)));
"BatchGet" + toUpperFirst(e.getName()), // BatchGet<Ent>
ENTITY_REQUEST_SUFFIX))))); props.put(
e.getName(), newSchema().oneOf(List.of(arraySchema, newSchema().type(TYPE_NULL))));
});
return newSchema() return newSchema()
.type(TYPE_OBJECT) .type(TYPE_OBJECT)
@ -1542,29 +1576,6 @@ public class OpenAPIV3Generator {
.properties(props); .properties(props);
} }
/** Same structure as buildEntityBatchGetRequestSchema but covers the union of all aspects. */
private static Schema buildEntitiesBatchGetRequestSchema(
Map<String, AspectSpec> aspectSpecs, Set<String> aspectNames) {
Map<String, Schema> properties =
aspectSpecs.entrySet().stream()
.filter(e -> aspectNames.contains(e.getKey()))
.collect(
Collectors.toMap(
Map.Entry::getKey,
e -> newSchema().$ref("#/components/schemas/BatchGetRequestBody"),
(a, b) -> a, // merge func (wont actually happen)
LinkedHashMap::new));
properties.put(PROPERTY_URN, newSchema().type(TYPE_STRING).description("Unique id for entity"));
return newSchema()
.type(TYPE_OBJECT)
.description(ENTITIES + " object.")
.required(List.of(PROPERTY_URN))
.properties(properties);
}
private static Schema buildAspectRef(final String aspect, final boolean withSystemMetadata) { private static Schema buildAspectRef(final String aspect, final boolean withSystemMetadata) {
final Schema result = newSchema(); final Schema result = newSchema();
@ -1968,7 +1979,10 @@ public class OpenAPIV3Generator {
NAME_SYSTEM_METADATA, NAME_SYSTEM_METADATA,
newSchema() newSchema()
.types(TYPE_OBJECT_NULLABLE) .types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata") .oneOf(
List.of(
newSchema().$ref(PATH_DEFINITIONS + "SystemMetadata"),
newSchema().type(TYPE_NULL)))
.description("System metadata for the aspect.")); .description("System metadata for the aspect."));
schema.addProperty( schema.addProperty(
"headers", "headers",

View File

@ -192,18 +192,36 @@ public class OpenAPIV3GeneratorTest {
@Test @Test
public void testBatchProperties() { public void testBatchProperties() {
Map<String, Schema> batchProperties = Map<String, Schema> batchProps =
openAPI openAPI
.getComponents() .getComponents()
.getSchemas() .getSchemas()
.get("BatchGetContainerEntityRequest_v3") .get("BatchGetContainerEntityRequest_v3")
.getProperties(); .getProperties();
batchProperties.entrySet().stream()
.filter(entry -> !entry.getKey().equals("urn")) batchProps.entrySet().stream()
.filter(e -> !e.getKey().equals("urn"))
.forEach( .forEach(
entry -> e -> {
assertEquals( Schema<?> prop = e.getValue();
"#/components/schemas/BatchGetRequestBody", entry.getValue().get$ref()));
// must be wrapped in oneOf
assertNull(prop.get$ref());
assertNotNull(prop.getOneOf());
assertEquals(prop.getOneOf().size(), 2);
boolean hasRef =
prop.getOneOf().stream()
.anyMatch(
s ->
"#/components/schemas/BatchGetRequestBody"
.equals(((Schema<?>) s).get$ref()));
boolean hasNull =
prop.getOneOf().stream().anyMatch(s -> "null".equals(((Schema<?>) s).getType()));
assertTrue(hasRef, "oneOf must contain BatchGetRequestBody ref");
assertTrue(hasNull, "oneOf must contain null type");
});
} }
@Test @Test
@ -232,10 +250,16 @@ public class OpenAPIV3GeneratorTest {
assertNull(created.getType()); assertNull(created.getType());
assertNull(created.getTypes()); assertNull(created.getTypes());
assertNull(created.get$ref()); assertNull(created.get$ref());
assertEquals( assertEquals(created.getOneOf().size(), 2);
new HashSet<>(created.getOneOf()),
Set.of(new Schema().$ref("#/components/schemas/TimeStamp"), new Schema<>().type("null"))); assertTrue(
assertNull(created.getNullable()); created.getOneOf().stream()
.anyMatch(s -> "#/components/schemas/TimeStamp".equals(((Schema<?>) s).get$ref())));
assertTrue(
created.getOneOf().stream()
.anyMatch(
s ->
((Schema<?>) s).get$ref() == null && "null".equals(((Schema<?>) s).getType())));
// Assert systemMetadata property on response schema is optional per v3.1.0 // Assert systemMetadata property on response schema is optional per v3.1.0
Map<String, Schema> datasetPropertiesResponseSchemaProps = Map<String, Schema> datasetPropertiesResponseSchemaProps =
@ -244,10 +268,23 @@ public class OpenAPIV3GeneratorTest {
.getSchemas() .getSchemas()
.get("DatasetPropertiesAspectResponse_v3") .get("DatasetPropertiesAspectResponse_v3")
.getProperties(); .getProperties();
Schema systemMetadata = datasetPropertiesResponseSchemaProps.get("systemMetadata"); Schema systemMetadataProperty = datasetPropertiesResponseSchemaProps.get("systemMetadata");
assertEquals(systemMetadata.getTypes(), Set.of("object", "null")); assertNotNull(systemMetadataProperty);
assertEquals(systemMetadata.get$ref(), "#/components/schemas/SystemMetadata");
assertNull(systemMetadata.getNullable()); assertNull(systemMetadataProperty.get$ref());
assertEquals(systemMetadataProperty.getTypes(), Set.of("object", "null"));
assertNotNull(systemMetadataProperty.getOneOf());
assertEquals(systemMetadataProperty.getOneOf().size(), 2);
boolean hasSysMetaRef =
systemMetadataProperty.getOneOf().stream()
.anyMatch(s -> "#/components/schemas/SystemMetadata".equals(((Schema<?>) s).get$ref()));
boolean hasNullAlt =
systemMetadataProperty.getOneOf().stream()
.anyMatch(s -> "null".equals(((Schema<?>) s).getType()));
assertTrue(hasSysMetaRef, "systemMetadata oneOf must contain SystemMetadata ref");
assertTrue(hasNullAlt, "systemMetadata oneOf must contain null type");
} }
@Test @Test
@ -592,20 +629,22 @@ public class OpenAPIV3GeneratorTest {
// Verify 'systemMetadata' property // Verify 'systemMetadata' property
Schema systemMetadataProperty = properties.get("systemMetadata"); Schema systemMetadataProperty = properties.get("systemMetadata");
assertNotNull( assertNotNull(systemMetadataProperty);
systemMetadataProperty, "AspectPatchProperty should have 'systemMetadata' property");
assertEquals( assertNull(systemMetadataProperty.get$ref());
systemMetadataProperty.get$ref(), assertEquals(systemMetadataProperty.getTypes(), Set.of("object", "null"));
"#/components/schemas/SystemMetadata", assertNotNull(systemMetadataProperty.getOneOf());
"SystemMetadata property should reference SystemMetadata schema"); assertEquals(systemMetadataProperty.getOneOf().size(), 2);
assertEquals(
systemMetadataProperty.getTypes(), boolean hasSysMetaRef =
Set.of("object", "null"), systemMetadataProperty.getOneOf().stream()
"SystemMetadata property should allow object and null types"); .anyMatch(s -> "#/components/schemas/SystemMetadata".equals(((Schema<?>) s).get$ref()));
assertEquals( boolean hasNullAlt =
systemMetadataProperty.getDescription(), systemMetadataProperty.getOneOf().stream()
"System metadata for the aspect.", .anyMatch(s -> "null".equals(((Schema<?>) s).getType()));
"SystemMetadata property should have correct description");
assertTrue(hasSysMetaRef, "systemMetadata oneOf must contain SystemMetadata ref");
assertTrue(hasNullAlt, "systemMetadata oneOf must contain null type");
// Verify 'headers' property // Verify 'headers' property
Schema headersProperty = properties.get("headers"); Schema headersProperty = properties.get("headers");
@ -730,6 +769,45 @@ public class OpenAPIV3GeneratorTest {
assertTrue(componentKeys.contains("CrossEntitiesResponse_v3")); assertTrue(componentKeys.contains("CrossEntitiesResponse_v3"));
} }
@Test
public void testCrossEntityArraysAreOptional() {
String[] schemas = {"CrossEntitiesRequest_v3", "CrossEntitiesPatch_v3"};
for (String schemaName : schemas) {
Schema<?> schema = openAPI.getComponents().getSchemas().get(schemaName);
assertNotNull(schema, "Component " + schemaName + " must exist");
// 1) No property except urn is required (required list null or empty)
assertTrue(
schema.getRequired() == null || schema.getRequired().isEmpty(),
schemaName + " must not require any entity arrays");
// 2) Every property value is oneOf( array , null )
schema
.getProperties()
.forEach(
(propName, propSchema) -> {
// Property schema should have no direct $ref and be wrapped in oneOf
assertNull(
propSchema.get$ref(),
schemaName + "." + propName + " must not have direct $ref");
assertNotNull(
propSchema.getOneOf(),
schemaName + "." + propName + " must be defined with oneOf");
List<Schema<?>> oneOf = propSchema.getOneOf();
assertEquals(oneOf.size(), 2, "oneOf must contain exactly two alternatives");
boolean hasArray = oneOf.stream().anyMatch(s -> "array".equals(s.getType()));
boolean hasNull = oneOf.stream().anyMatch(s -> "null".equals(s.getType()));
assertTrue(
hasArray, schemaName + "." + propName + " oneOf must include array type");
assertTrue(hasNull, schemaName + "." + propName + " oneOf must include null type");
});
}
}
private JsonSchema loadOpenAPI31Schema(JsonSchemaFactory schemaFactory) throws Exception { private JsonSchema loadOpenAPI31Schema(JsonSchemaFactory schemaFactory) throws Exception {
URL schemaUrl = new URL("https://spec.openapis.org/oas/3.1/schema/2022-10-07"); URL schemaUrl = new URL("https://spec.openapis.org/oas/3.1/schema/2022-10-07");
return schemaFactory.getSchema(schemaUrl.openStream()); return schemaFactory.getSchema(schemaUrl.openStream());