feat(openapi-31): properly update openapi spec to 3.1.0 (#13828)

This commit is contained in:
david-leifker 2025-06-21 15:41:12 -05:00 committed by GitHub
parent 889441217b
commit e9103f1851
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1581 additions and 264 deletions

View File

@ -269,7 +269,7 @@ project.ext.externalDependency = [
'springBeans': "org.springframework:spring-beans:$springVersion",
'springContext': "org.springframework:spring-context:$springVersion",
'springCore': "org.springframework:spring-core:$springVersion",
'springDocUI': 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6',
'springDocUI': 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9',
'springJdbc': "org.springframework:spring-jdbc:$springVersion",
'springWeb': "org.springframework:spring-web:$springVersion",
'springWebMVC': "org.springframework:spring-webmvc:$springVersion",
@ -286,7 +286,7 @@ project.ext.externalDependency = [
'swaggerAnnotations': 'io.swagger.core.v3:swagger-annotations:2.2.30',
'swaggerCli': 'io.swagger.codegen.v3:swagger-codegen-cli:3.0.46',
'swaggerCore': 'io.swagger.core.v3:swagger-core:2.2.30',
'swaggerParser': 'io.swagger.parser.v3:swagger-parser:2.1.26',
'swaggerParser': 'io.swagger.parser.v3:swagger-parser:2.1.27',
'springBootAutoconfigureJdk11': 'org.springframework.boot:spring-boot-autoconfigure:2.7.18',
'testng': 'org.testng:testng:7.8.0',
'testContainers': 'org.testcontainers:testcontainers:' + testContainersVersion,

View File

@ -50,6 +50,7 @@ This file documents any backwards-incompatible changes in DataHub and assists pe
- #13397: Ingestion Rest Emitter
- ASYNC_WAIT/ASYNC - Async modes are impacted by kafka lag.
- SYNC_WAIT - Only available with OpenAPI ingestion
- OpenAPI Reports OpenAPI Spec 3.1.0 when it only supports 3.0.1
### Potential Downtime

View File

@ -417,6 +417,9 @@ springdoc:
urls-primary-name: DataHub v3 (OpenAPI)
api-docs:
path: /openapi/v3/api-docs
# Do not change this even though some APIs are using 3.1
# Serialization of enum types will break
version: openapi_3_0
groups:
enabled: true

View File

@ -16,7 +16,9 @@ dependencies {
implementation externalDependency.springBoot
implementation externalDependency.springCore
implementation externalDependency.springDocUI
implementation(externalDependency.springDocUI) {
exclude group: 'org.springframework.boot'
}
implementation externalDependency.springWeb
implementation externalDependency.springWebMVC
implementation externalDependency.springBeans

View File

@ -20,7 +20,9 @@ dependencies {
}
implementation externalDependency.springBoot
implementation externalDependency.springCore
implementation externalDependency.springDocUI
implementation(externalDependency.springDocUI) {
exclude group: 'org.springframework.boot'
}
implementation externalDependency.springWeb
implementation externalDependency.springWebMVC
implementation externalDependency.springBeans

View File

@ -25,6 +25,7 @@ dependencies {
implementation externalDependency.springWebMVC
implementation externalDependency.springBeans
implementation externalDependency.springContext
implementation externalDependency.swaggerCore
implementation externalDependency.servletApi
implementation externalDependency.slf4jApi
compileOnly externalDependency.lombok
@ -56,6 +57,7 @@ dependencies {
testImplementation externalDependency.jacksonDataBind
testImplementation externalDependency.springBootStarterWeb
testImplementation externalDependency.swaggerParser
testImplementation 'com.networknt:json-schema-validator:1.5.7'
// Openlineage Specific Dependencies
implementation "io.openlineage:openlineage-java:$openLineageVersion"

View File

@ -2,13 +2,11 @@ package io.datahubproject.openapi.config;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.EntityRegistry;
import io.datahubproject.openapi.v3.OpenAPIV3Generator;
import io.datahubproject.openapi.v3.OpenAPIV3Customizer;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.servers.Server;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import java.util.Collections;
import io.swagger.v3.oas.models.SpecVersion;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
@ -32,6 +30,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Order(2)
@Configuration
public class SpringWebConfig implements WebMvcConfigurer {
private static final String LEGACY_VERSION = "3.0.1";
private static final Set<String> OPERATIONS_PACKAGES =
Set.of("io.datahubproject.openapi.operations", "io.datahubproject.openapi.health");
private static final Set<String> V1_PACKAGES = Set.of("io.datahubproject.openapi.v1");
@ -52,31 +51,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
.group("10-openapi-v3")
.displayName("DataHub v3 (OpenAPI)")
.addOpenApiCustomizer(
openApi -> {
OpenAPI v3OpenApi =
OpenAPIV3Generator.generateOpenApiSpec(entityRegistry, configurationProvider);
openApi.setInfo(v3OpenApi.getInfo());
openApi.setTags(Collections.emptyList());
openApi.getPaths().putAll(v3OpenApi.getPaths());
// Merge components. Swagger does not provide append method to add components.
final Components components = new Components();
final Components oComponents = openApi.getComponents();
final Components v3Components = v3OpenApi.getComponents();
components
.callbacks(concat(oComponents::getCallbacks, v3Components::getCallbacks))
.examples(concat(oComponents::getExamples, v3Components::getExamples))
.extensions(concat(oComponents::getExtensions, v3Components::getExtensions))
.headers(concat(oComponents::getHeaders, v3Components::getHeaders))
.links(concat(oComponents::getLinks, v3Components::getLinks))
.parameters(concat(oComponents::getParameters, v3Components::getParameters))
.requestBodies(
concat(oComponents::getRequestBodies, v3Components::getRequestBodies))
.responses(concat(oComponents::getResponses, v3Components::getResponses))
.schemas(concat(oComponents::getSchemas, v3Components::getSchemas))
.securitySchemes(
concat(oComponents::getSecuritySchemes, v3Components::getSecuritySchemes));
openApi.setComponents(components);
})
openApi ->
OpenAPIV3Customizer.customizer(openApi, entityRegistry, configurationProvider))
.packagesToScan(V3_PACKAGES.toArray(String[]::new))
.build();
}
@ -86,6 +62,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
return GroupedOpenApi.builder()
.group("20-openapi-v2")
.displayName("DataHub v2 (OpenAPI)")
.addOpenApiCustomizer(
openApi -> openApi.specVersion(SpecVersion.V30).openapi(LEGACY_VERSION))
.packagesToScan(V2_PACKAGES.toArray(String[]::new))
.build();
}
@ -95,6 +73,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
return GroupedOpenApi.builder()
.group("30-openapi-v1")
.displayName("DataHub v1 (OpenAPI)")
.addOpenApiCustomizer(
openApi -> openApi.specVersion(SpecVersion.V30).openapi(LEGACY_VERSION))
.packagesToScan(V1_PACKAGES.toArray(String[]::new))
.build();
}
@ -104,6 +84,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
return GroupedOpenApi.builder()
.group("40-operations")
.displayName("Operations")
.addOpenApiCustomizer(
openApi -> openApi.specVersion(SpecVersion.V30).openapi(LEGACY_VERSION))
.packagesToScan(OPERATIONS_PACKAGES.toArray(String[]::new))
.build();
}
@ -113,6 +95,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
return GroupedOpenApi.builder()
.group("50-openlineage")
.displayName("OpenLineage")
.addOpenApiCustomizer(
openApi -> openApi.specVersion(SpecVersion.V30).openapi(LEGACY_VERSION))
.packagesToScan(OPENLINEAGE_PACKAGES.toArray(String[]::new))
.build();
}
@ -123,6 +107,8 @@ public class SpringWebConfig implements WebMvcConfigurer {
return GroupedOpenApi.builder()
.group("70-events")
.displayName("Events")
.addOpenApiCustomizer(
openApi -> openApi.specVersion(SpecVersion.V30).openapi(LEGACY_VERSION))
.packagesToScan(EVENTS_PACKAGES.toArray(String[]::new))
.build();
}

View File

@ -0,0 +1,103 @@
package io.datahubproject.openapi.v3;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.EntityRegistry;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Paths;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
public class OpenAPIV3Customizer {
private OpenAPIV3Customizer() {}
public static void customizer(
OpenAPI springOpenAPI,
EntityRegistry entityRegistry,
final ConfigurationProvider configurationProvider) {
OpenAPI registryOpenAPI =
OpenAPIV3Generator.generateOpenApiSpec(entityRegistry, configurationProvider);
springOpenAPI.specVersion(registryOpenAPI.getSpecVersion());
springOpenAPI.openapi(registryOpenAPI.getOpenapi());
springOpenAPI.setInfo(registryOpenAPI.getInfo());
springOpenAPI.setTags(Collections.emptyList());
// Merge paths with null checking
if (registryOpenAPI.getPaths() != null) {
if (springOpenAPI.getPaths() == null) {
springOpenAPI.setPaths(new Paths());
}
springOpenAPI.getPaths().putAll(registryOpenAPI.getPaths());
}
// Merge components with custom ones taking precedence
final Components components = new Components();
final Components oComponents = springOpenAPI.getComponents();
final Components v3Components = registryOpenAPI.getComponents();
components
.callbacks(
mergeWithPrecedence(
oComponents != null ? oComponents::getCallbacks : () -> null,
v3Components != null ? v3Components::getCallbacks : () -> null))
.examples(
mergeWithPrecedence(
oComponents != null ? oComponents::getExamples : () -> null,
v3Components != null ? v3Components::getExamples : () -> null))
.extensions(
mergeWithPrecedence(
oComponents != null ? oComponents::getExtensions : () -> null,
v3Components != null ? v3Components::getExtensions : () -> null))
.headers(
mergeWithPrecedence(
oComponents != null ? oComponents::getHeaders : () -> null,
v3Components != null ? v3Components::getHeaders : () -> null))
.links(
mergeWithPrecedence(
oComponents != null ? oComponents::getLinks : () -> null,
v3Components != null ? v3Components::getLinks : () -> null))
.parameters(
mergeWithPrecedence(
oComponents != null ? oComponents::getParameters : () -> null,
v3Components != null ? v3Components::getParameters : () -> null))
.requestBodies(
mergeWithPrecedence(
oComponents != null ? oComponents::getRequestBodies : () -> null,
v3Components != null ? v3Components::getRequestBodies : () -> null))
.responses(
mergeWithPrecedence(
oComponents != null ? oComponents::getResponses : () -> null,
v3Components != null ? v3Components::getResponses : () -> null))
.schemas(
mergeWithPrecedence(
oComponents != null ? oComponents::getSchemas : () -> null,
v3Components != null ? v3Components::getSchemas : () -> null))
.securitySchemes(
mergeWithPrecedence(
oComponents != null ? oComponents::getSecuritySchemes : () -> null,
v3Components != null ? v3Components::getSecuritySchemes : () -> null));
springOpenAPI.setComponents(components);
}
private static <T> Map<String, T> mergeWithPrecedence(
Supplier<Map<String, T>> springComponents, Supplier<Map<String, T>> registryComponents) {
Map<String, T> result = new LinkedHashMap<>();
// First add Spring-generated components
Map<String, T> springMap = springComponents.get();
if (springMap != null) {
result.putAll(springMap);
}
// Then add custom components, which will override any conflicts
Map<String, T> customMap = registryComponents.get();
if (customMap != null) {
result.putAll(customMap);
}
return result;
}
}

View File

@ -40,6 +40,8 @@ import javax.annotation.Nonnull;
@SuppressWarnings({"rawtypes", "unchecked"})
public class OpenAPIV3Generator {
private static final SpecVersion SPEC_VERSION = SpecVersion.V31;
private static final String PATH_PREFIX = "/openapi/v3";
private static final String MODEL_VERSION = "_v3";
private static final String TYPE_OBJECT = "object";
@ -47,6 +49,10 @@ public class OpenAPIV3Generator {
private static final String TYPE_STRING = "string";
private static final String TYPE_ARRAY = "array";
private static final String TYPE_INTEGER = "integer";
private static final String TYPE_NULL = "null";
private static final Set<String> TYPE_OBJECT_NULLABLE = Set.of(TYPE_OBJECT, TYPE_NULL);
private static final Set<String> TYPE_STRING_NULLABLE = Set.of(TYPE_STRING, TYPE_NULL);
private static final Set<String> TYPE_INTEGER_NULLABLE = Set.of(TYPE_INTEGER, TYPE_NULL);
private static final String NAME_QUERY = "query";
private static final String NAME_PATH = "path";
private static final String NAME_SYSTEM_METADATA = "systemMetadata";
@ -77,6 +83,7 @@ public class OpenAPIV3Generator {
private static final Set<String> EXCLUDE_ENTITIES = Set.of("dataHubOpenAPISchema");
private static final Set<String> EXCLUDE_ASPECTS = Set.of("dataHubOpenAPISchemaKey");
private static final String ASPECT_PATCH_PROPERTY = "AspectPatchProperty";
public static OpenAPI generateOpenApiSpec(
EntityRegistry entityRegistry, ConfigurationProvider configurationProvider) {
@ -107,23 +114,22 @@ public class OpenAPIV3Generator {
buildEntitySchema(filteredAspectSpec, aspectNames, true));
components.addSchemas(
"Scroll" + ENTITIES + ENTITY_RESPONSE_SUFFIX, buildEntitiesScrollSchema());
components.addSchemas(ASPECT_PATCH_PROPERTY, buildAspectPatchPropertySchema());
// --> Aspect components
components.addSchemas(ASPECT_PATCH, buildAspectPatchSchema());
components.addSchemas(
"BatchGetRequestBody",
new Schema<>()
.type(TYPE_OBJECT)
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.description("Request body for batch get aspects.")
.properties(
Map.of(
"headers",
new Schema<>()
.type(TYPE_OBJECT)
.additionalProperties(new Schema<>().type(TYPE_STRING))
.description("System headers for the operation.")
.nullable(true)))
.nullable(true));
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.additionalProperties(newSchema().type(TYPE_STRING))
.description("System headers for the operation."))));
// --> Aspect components
filteredAspectSpec
@ -163,7 +169,8 @@ public class OpenAPIV3Generator {
buildEntityPatchSchema(e, aspectNames, true));
});
components.addSchemas("SortOrder", new Schema()._enum(List.of("ASCENDING", "DESCENDING")));
components.addSchemas(
"SortOrder", newSchema().type(TYPE_STRING)._enum(List.of("ASCENDING", "DESCENDING")));
// Parameters
@ -239,7 +246,11 @@ public class OpenAPIV3Generator {
});
}
return new OpenAPI().openapi("3.0.1").info(info).paths(paths).components(components);
return new OpenAPI(SPEC_VERSION)
.openapi("3.1.0")
.info(info)
.paths(paths)
.components(components);
}
private static PathItem buildSingleEntityPath(final EntitySpec entity) {
@ -254,12 +265,12 @@ public class OpenAPIV3Generator {
.in(NAME_PATH)
.name("urn")
.description("The entity's unique URN id.")
.schema(new Schema().type(TYPE_STRING)),
.schema(newSchema().type(TYPE_STRING)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.$ref(
String.format(
@ -273,7 +284,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -303,12 +314,12 @@ public class OpenAPIV3Generator {
.in(NAME_PATH)
.name("urn")
.description("The entity's unique URN id.")
.schema(new Schema().type(TYPE_STRING)),
.schema(newSchema().type(TYPE_STRING)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_INCLUDE_SOFT_DELETE)
.description("If enabled, soft deleted items will exist.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.tags(List.of(entity.getName() + " Entity"))
.responses(
new ApiResponses()
@ -329,12 +340,12 @@ public class OpenAPIV3Generator {
.in(NAME_PATH)
.name("urn")
.description("The entity's unique URN id.")
.schema(new Schema().type(TYPE_STRING)),
.schema(newSchema().type(TYPE_STRING)),
new Parameter()
.in(NAME_QUERY)
.name("clear")
.description("Delete all aspects, preserving the entity's key aspect.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.$ref(
String.format(
@ -364,17 +375,17 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_INCLUDE_SOFT_DELETE)
.description("Include soft-deleted aspects with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SKIP_CACHE)
.description("Skip cache when listing entities.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.$ref(
String.format(
@ -384,7 +395,7 @@ public class OpenAPIV3Generator {
.name(NAME_PIT_KEEP_ALIVE)
.description(
"Point In Time keep alive, accepts a time based string like \"5m\" for five minutes.")
.schema(new Schema().type(TYPE_STRING)._default("5m")),
.schema(newSchema().type(TYPE_STRING)._default("5m")),
new Parameter().$ref("#/components/parameters/PaginationCount" + MODEL_VERSION),
new Parameter().$ref("#/components/parameters/ScrollId" + MODEL_VERSION),
new Parameter().$ref("#/components/parameters/SortBy" + MODEL_VERSION),
@ -399,7 +410,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/Scroll%s%s",
@ -420,10 +431,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -437,10 +448,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -458,12 +469,12 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name("async")
.description("Use async ingestion for high throughput.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(true)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(true)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.summary("Create " + upperFirst + " entities.")
.tags(List.of(entity.getName() + " Entity"))
.requestBody(
@ -483,10 +494,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -500,10 +511,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -521,12 +532,12 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name("async")
.description("Use async ingestion for high throughput.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(true)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(true)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.summary("Patch " + upperFirst + " entities.")
.tags(List.of(entity.getName() + " Entity"))
.requestBody(
@ -552,10 +563,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -569,10 +580,10 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -587,7 +598,7 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.requestBody(
new RequestBody()
.description("Batch Get " + entity.getName() + " entities.")
@ -606,23 +617,23 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_INCLUDE_SOFT_DELETE)
.description("Include soft-deleted aspects with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SKIP_CACHE)
.description("Skip cache when listing entities.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_PIT_KEEP_ALIVE)
.description(
"Point In Time keep alive, accepts a time based string like \"5m\" for five minutes.")
.schema(new Schema().type(TYPE_STRING)._default("5m").nullable(true)),
.schema(newSchema().types(TYPE_STRING_NULLABLE)._default("5m")),
new Parameter().$ref("#/components/parameters/PaginationCount" + MODEL_VERSION),
new Parameter().$ref("#/components/parameters/ScrollId" + MODEL_VERSION),
new Parameter().$ref("#/components/parameters/SortBy" + MODEL_VERSION),
@ -637,7 +648,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/Scroll%s%s",
@ -654,7 +665,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -680,7 +691,7 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SCROLL_ID)
.description("Scroll pagination token.")
.schema(new Schema().type(TYPE_STRING)));
.schema(newSchema().type(TYPE_STRING)));
components.addParameters(
"SortBy" + MODEL_VERSION,
new Parameter()
@ -690,10 +701,10 @@ public class OpenAPIV3Generator {
.description("Sort fields for pagination.")
.example(PROPERTY_URN)
.schema(
new Schema()
newSchema()
.type(TYPE_ARRAY)
._default(List.of(PROPERTY_URN))
.items(new Schema<>().type(TYPE_STRING)._default(PROPERTY_URN))));
.items(newSchema().type(TYPE_STRING)._default(PROPERTY_URN))));
components.addParameters(
"SortOrder" + MODEL_VERSION,
new Parameter()
@ -702,7 +713,7 @@ public class OpenAPIV3Generator {
.explode(true)
.description("Sort direction field for pagination.")
.example("ASCENDING")
.schema(new Schema()._default("ASCENDING").$ref("#/components/schemas/SortOrder")));
.schema(newSchema()._default("ASCENDING").$ref("#/components/schemas/SortOrder")));
components.addParameters(
"PaginationCount" + MODEL_VERSION,
new Parameter()
@ -711,7 +722,7 @@ public class OpenAPIV3Generator {
.description("Number of items per page.")
.example(10)
.schema(
new Schema()
newSchema()
.type(TYPE_INTEGER)
._default(searchResultsLimit.getApiDefault())
.maximum(BigDecimal.valueOf(searchResultsLimit.getMax()))
@ -724,7 +735,7 @@ public class OpenAPIV3Generator {
.description(
"Structured search query. See Elasticsearch documentation on `query_string` syntax.")
.example("*")
.schema(new Schema().type(TYPE_STRING)._default("*")));
.schema(newSchema().type(TYPE_STRING)._default("*")));
}
private static Parameter buildParameterSchema(
@ -739,10 +750,10 @@ public class OpenAPIV3Generator {
aspectNames.add(entity.getKeyAspectName());
}
final Schema schema =
new Schema()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema()
newSchema()
.type(TYPE_STRING)
._enum(aspectNames.stream().sorted().toList())
._default(aspectNames.stream().findFirst().orElse(null)));
@ -771,10 +782,15 @@ public class OpenAPIV3Generator {
final String newDefinition =
definition.replaceAll("definitions", "components/schemas");
Schema s = Json.mapper().readValue(newDefinition, Schema.class);
s.specVersion(SPEC_VERSION);
// Set enums to "string".
if (s.getEnum() != null && !s.getEnum().isEmpty()) {
s.setType("string");
if (s.getNullable() != null && s.getNullable()) {
nullableSchema(s, TYPE_STRING_NULLABLE);
} else {
s.setType(TYPE_STRING);
}
} else {
Set<String> requiredNames =
Optional.ofNullable(s.getRequired())
@ -785,6 +801,7 @@ public class OpenAPIV3Generator {
Optional.ofNullable(s.getProperties()).orElse(new HashMap<>());
properties.forEach(
(name, schema) -> {
schema.specVersion(SpecVersion.V31);
String $ref = schema.get$ref();
boolean isNameRequired = requiredNames.contains(name);
@ -797,6 +814,9 @@ public class OpenAPIV3Generator {
if (hasDefault) {
// A default value means it is not required, regardless of nullability
s.getRequired().remove(name);
if (s.getRequired().isEmpty()) {
s.setRequired(null);
}
}
}
@ -804,11 +824,24 @@ public class OpenAPIV3Generator {
// A non-required $ref property must be wrapped in a { anyOf: [ $ref ] }
// object to allow the
// property to be marked as nullable
schema.setType(TYPE_OBJECT);
schema.setType(null);
schema.set$ref(null);
schema.setAnyOf(List.of(new Schema().$ref($ref)));
schema.setAnyOf(
List.of(newSchema().$ref($ref), newSchema().type(TYPE_NULL)));
}
if ($ref == null) {
if (schema.getEnum() != null && !schema.getEnum().isEmpty()) {
if ((schema.getNullable() != null && schema.getNullable())
|| !isNameRequired) {
nullableSchema(schema, TYPE_STRING_NULLABLE);
} else {
schema.setType(TYPE_STRING);
}
} else if (schema.getEnum() == null && !isNameRequired) {
nullableSchema(schema, Set.of(schema.getType(), TYPE_NULL));
}
}
schema.setNullable(!isNameRequired);
});
}
components.addSchemas(n, s);
@ -823,53 +856,49 @@ public class OpenAPIV3Generator {
private static Schema buildAspectRefResponseSchema(final String aspectName) {
final Schema result =
new Schema<>()
newSchema()
.type(TYPE_OBJECT)
.description(ASPECT_DESCRIPTION)
.required(List.of(PROPERTY_VALUE))
.addProperty(PROPERTY_VALUE, new Schema<>().$ref(PATH_DEFINITIONS + aspectName));
.addProperty(PROPERTY_VALUE, newSchema().$ref(PATH_DEFINITIONS + aspectName));
result.addProperty(
NAME_SYSTEM_METADATA,
new Schema<>()
.type(TYPE_OBJECT)
.anyOf(List.of(new Schema().$ref(PATH_DEFINITIONS + "SystemMetadata")))
.description("System metadata for the aspect.")
.nullable(true));
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata")
.description("System metadata for the aspect."));
result.addProperty(
NAME_AUDIT_STAMP,
new Schema<>()
.type(TYPE_OBJECT)
.anyOf(List.of(new Schema().$ref(PATH_DEFINITIONS + "AuditStamp")))
.description("Audit stamp for the aspect.")
.nullable(true));
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "AuditStamp")
.description("Audit stamp for the aspect."));
return result;
}
private static Schema buildAspectRefRequestSchema(final String aspectName) {
final Schema result =
new Schema<>()
newSchema()
.type(TYPE_OBJECT)
.description(ASPECT_DESCRIPTION)
.required(List.of(PROPERTY_VALUE))
.addProperty(
PROPERTY_VALUE, new Schema<>().$ref(PATH_DEFINITIONS + toUpperFirst(aspectName)));
PROPERTY_VALUE, newSchema().$ref(PATH_DEFINITIONS + toUpperFirst(aspectName)));
result.addProperty(
NAME_SYSTEM_METADATA,
new Schema<>()
.type(TYPE_OBJECT)
.anyOf(List.of(new Schema().$ref(PATH_DEFINITIONS + "SystemMetadata")))
.description("System metadata for the aspect.")
.nullable(true));
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata")
.description("System metadata for the aspect."));
Schema stringTypeSchema = new Schema<>();
Schema stringTypeSchema = newSchema();
stringTypeSchema.setType(TYPE_STRING);
result.addProperty(
"headers",
new Schema<>()
.type(TYPE_OBJECT)
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.additionalProperties(stringTypeSchema)
.description("System headers for the operation.")
.nullable(true));
.description("System headers for the operation."));
return result;
}
@ -878,7 +907,7 @@ public class OpenAPIV3Generator {
final Map<String, Schema> properties = new LinkedHashMap<>();
properties.put(
PROPERTY_URN,
new Schema<>().type(TYPE_STRING).description("Unique id for " + entity.getName()));
newSchema().type(TYPE_STRING).description("Unique id for " + entity.getName()));
final Map<String, Schema> aspectProperties =
entity.getAspectSpecMap().entrySet().stream()
@ -895,7 +924,7 @@ public class OpenAPIV3Generator {
entity.getKeyAspectName(),
buildAspectRef(entity.getKeyAspectSpec().getPegasusSchema().getName(), withSystemMetadata));
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(toUpperFirst(entity.getName()) + " object.")
.required(List.of(PROPERTY_URN))
@ -915,42 +944,14 @@ public class OpenAPIV3Generator {
.collect(
Collectors.toMap(
Map.Entry::getKey,
a ->
new Schema<>()
.type(TYPE_OBJECT)
.required(List.of(PROPERTY_VALUE))
.addProperty(
PROPERTY_VALUE,
new Schema<>()
.type(TYPE_OBJECT)
.anyOf(
List.of(new Schema().$ref(PATH_DEFINITIONS + ASPECT_PATCH)))
.description("Patch to apply to the aspect.")
.nullable(false))
.addProperty(
NAME_SYSTEM_METADATA,
new Schema<>()
.type(TYPE_OBJECT)
.anyOf(
List.of(
new Schema().$ref(PATH_DEFINITIONS + "SystemMetadata")))
.description("System metadata for the aspect.")
.nullable(true))
.addProperty(
"headers",
new Schema<>()
.type(TYPE_OBJECT)
.additionalProperties(new Schema<>().type(TYPE_STRING))
.description("System headers for the operation.")
.nullable(true))
.nullable(true)));
a -> newSchema().$ref(PATH_DEFINITIONS + ASPECT_PATCH_PROPERTY)));
final Map<String, Schema> properties = new LinkedHashMap<>();
properties.put(
PROPERTY_URN, new Schema<>().type(TYPE_STRING).description("Unique id for " + ENTITIES));
PROPERTY_URN, newSchema().type(TYPE_STRING).description("Unique id for " + ENTITIES));
properties.putAll(patchProperties);
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(ENTITIES + " object.")
.required(List.of(PROPERTY_URN))
@ -978,9 +979,9 @@ public class OpenAPIV3Generator {
buildAspectRef(
a.getValue().getPegasusSchema().getName(), withSystemMetadata)));
properties.put(
PROPERTY_URN, new Schema<>().type(TYPE_STRING).description("Unique id for " + ENTITIES));
PROPERTY_URN, newSchema().type(TYPE_STRING).description("Unique id for " + ENTITIES));
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(ENTITIES + " object.")
.required(List.of(PROPERTY_URN))
@ -1010,7 +1011,7 @@ public class OpenAPIV3Generator {
.toList();
Schema entitiesSchema =
new Schema().type(TYPE_ARRAY).items(new Schema().type(TYPE_STRING)._enum(entityNames));
newSchema().type(TYPE_ARRAY).items(newSchema().type(TYPE_STRING)._enum(entityNames));
final List<String> aspectNames =
aspectSpecs.values().stream()
@ -1022,9 +1023,9 @@ public class OpenAPIV3Generator {
.collect(Collectors.toList());
Schema aspectsSchema =
new Schema().type(TYPE_ARRAY).items(new Schema().type(TYPE_STRING)._enum(aspectNames));
newSchema().type(TYPE_ARRAY).items(newSchema().type(TYPE_STRING)._enum(aspectNames));
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(ENTITIES + " request object.")
.example(
@ -1046,40 +1047,38 @@ public class OpenAPIV3Generator {
* @return schema
*/
private static Schema buildEntitiesScrollSchema() {
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description("Scroll across (list) " + ENTITIES + " objects.")
.required(List.of("entities"))
.addProperty(
NAME_SCROLL_ID,
new Schema<>().type(TYPE_STRING).description("Scroll id for pagination."))
NAME_SCROLL_ID, newSchema().type(TYPE_STRING).description("Scroll id for pagination."))
.addProperty(
"entities",
new Schema<>()
newSchema()
.type(TYPE_ARRAY)
.description(ENTITIES + " object.")
.items(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s", ENTITIES, ENTITY_RESPONSE_SUFFIX))));
}
private static Schema buildEntityScrollSchema(final EntitySpec entity) {
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description("Scroll across (list) " + toUpperFirst(entity.getName()) + " objects.")
.required(List.of("entities"))
.addProperty(
NAME_SCROLL_ID,
new Schema<>().type(TYPE_STRING).description("Scroll id for pagination."))
NAME_SCROLL_ID, newSchema().type(TYPE_STRING).description("Scroll id for pagination."))
.addProperty(
"entities",
new Schema<>()
newSchema()
.type(TYPE_ARRAY)
.description(toUpperFirst(entity.getName()) + " object.")
.items(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1095,15 +1094,15 @@ public class OpenAPIV3Generator {
.collect(
Collectors.toMap(
Map.Entry::getKey,
a -> new Schema().$ref("#/components/schemas/BatchGetRequestBody")));
a -> newSchema().$ref("#/components/schemas/BatchGetRequestBody")));
properties.put(
PROPERTY_URN,
new Schema<>().type(TYPE_STRING).description("Unique id for " + entity.getName()));
newSchema().type(TYPE_STRING).description("Unique id for " + entity.getName()));
properties.put(
entity.getKeyAspectName(), new Schema().$ref("#/components/schemas/BatchGetRequestBody"));
entity.getKeyAspectName(), newSchema().$ref("#/components/schemas/BatchGetRequestBody"));
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(toUpperFirst(entity.getName()) + " object.")
.required(List.of(PROPERTY_URN))
@ -1111,10 +1110,7 @@ public class OpenAPIV3Generator {
}
private static Schema buildAspectRef(final String aspect, final boolean withSystemMetadata) {
// A non-required $ref property must be wrapped in a { anyOf: [ $ref ] }
// object to allow the
// property to be marked as nullable
final Schema result = new Schema<>();
final Schema result = newSchema();
result.setType(TYPE_OBJECT);
result.set$ref(null);
@ -1127,7 +1123,7 @@ public class OpenAPIV3Generator {
internalRef =
String.format(FORMAT_PATH_DEFINITIONS, toUpperFirst(aspect), ASPECT_REQUEST_SUFFIX);
}
result.setAnyOf(List.of(new Schema().$ref(internalRef)));
result.setAnyOf(List.of(newSchema().$ref(internalRef)));
return result;
}
@ -1136,17 +1132,17 @@ public class OpenAPIV3Generator {
ImmutableMap.<String, Schema>builder()
.put(
PROPERTY_PATCH,
new Schema<>()
newSchema()
.type(TYPE_ARRAY)
.items(
new Schema<>()
newSchema()
.type(TYPE_OBJECT)
.required(List.of("op", "path"))
.additionalProperties(false)
.properties(
Map.of(
"op",
new Schema<>()
newSchema()
.type(TYPE_STRING)
.description("Operation type")
._enum(
@ -1154,37 +1150,37 @@ public class OpenAPIV3Generator {
"add", "remove", "replace", "move", "copy",
"test")),
"path",
new Schema<>()
newSchema()
.type(TYPE_STRING)
.description("JSON pointer to the target location")
.format("json-pointer"),
"from",
new Schema<>()
newSchema()
.type(TYPE_STRING)
.description(
"JSON pointer for source location (required for move/copy)"),
"value",
new Schema<>() // No type restriction to allow any JSON
newSchema() // No type restriction to allow any JSON
// value
.description(
"The value to use for this operation (if applicable)")))))
.put(
ARRAY_PRIMARY_KEYS_FIELD,
new Schema<>()
newSchema()
.type(TYPE_OBJECT)
.description("Maps array paths to their primary key field names")
.additionalProperties(
new Schema<>().type(TYPE_ARRAY).items(new Schema<>().type(TYPE_STRING))))
newSchema().type(TYPE_ARRAY).items(newSchema().type(TYPE_STRING))))
.put(
"forceGenericPatch",
new Schema<>()
newSchema()
.type(TYPE_BOOLEAN)
._default(false)
.description(
"Flag to force using generic patching regardless of other conditions"))
.build();
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description(
"Extended JSON Patch to allow for manipulating array sets which represent maps where each element has a unique primary key.")
@ -1203,7 +1199,7 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false));
.schema(newSchema().type(TYPE_BOOLEAN)._default(false));
final Parameter versionParameter =
new Parameter()
.in(NAME_QUERY)
@ -1212,7 +1208,7 @@ public class OpenAPIV3Generator {
aspectSpec.isTimeseries()
? "This aspect is a `timeseries` aspect, version=0 indicates the most recent aspect should be return. Otherwise return the most recent <= to version as epoch milliseconds."
: "Return a specific aspect version of the aspect.")
.schema(new Schema().type(TYPE_INTEGER)._default(0).minimum(BigDecimal.ZERO));
.schema(newSchema().type(TYPE_INTEGER)._default(0).minimum(BigDecimal.ZERO));
final ApiResponse successApiResponse =
new ApiResponse()
.description("Success")
@ -1222,7 +1218,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1254,7 +1250,7 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_INCLUDE_SOFT_DELETE)
.description("If enabled, soft deleted items will exist.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.responses(
new ApiResponses()
.addApiResponse("200", successHeadResponse)
@ -1283,7 +1279,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1301,7 +1297,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1318,22 +1314,22 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name("async")
.description("Use async ingestion for high throughput.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name("createIfEntityNotExists")
.description("Only create the aspect if the Entity doesn't exist.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false)),
.schema(newSchema().type(TYPE_BOOLEAN)._default(false)),
new Parameter()
.in(NAME_QUERY)
.name("createIfNotExists")
.description("Only create the aspect if the Aspect doesn't exist.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(true))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(true))))
.requestBody(requestBody)
.responses(new ApiResponses().addApiResponse("201", successPostResponse));
// Patch Operation
@ -1348,7 +1344,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1366,7 +1362,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema()
newSchema()
.$ref(
String.format("#/components/schemas/%s", ASPECT_PATCH)))));
final Operation patchOperation =
@ -1377,7 +1373,7 @@ public class OpenAPIV3Generator {
.in(NAME_QUERY)
.name(NAME_SYSTEM_METADATA)
.description("Include systemMetadata with response.")
.schema(new Schema().type(TYPE_BOOLEAN)._default(false))))
.schema(newSchema().type(TYPE_BOOLEAN)._default(false))))
.summary(
String.format("Patch aspect %s on %s ", aspectSpec.getName(), upperFirstEntity))
.tags(tags)
@ -1390,7 +1386,7 @@ public class OpenAPIV3Generator {
.in("path")
.name("urn")
.required(true)
.schema(new Schema().type(TYPE_STRING))))
.schema(newSchema().type(TYPE_STRING))))
.get(getOperation)
.head(headOperation)
.delete(deleteOperation)
@ -1399,31 +1395,25 @@ public class OpenAPIV3Generator {
}
private static Schema buildVersionPropertiesRequestSchema() {
return new Schema<>()
return newSchema()
.type(TYPE_OBJECT)
.description("Properties for creating a version relationship")
.properties(
Map.of(
"comment",
new Schema<>()
.type(TYPE_STRING)
.description("Comment about the version")
.nullable(true),
newSchema()
.types(TYPE_STRING_NULLABLE)
.description("Comment about the version"),
"label",
new Schema<>()
.type(TYPE_STRING)
.description("Label for the version")
.nullable(true),
newSchema().types(TYPE_STRING_NULLABLE).description("Label for the version"),
"sourceCreationTimestamp",
new Schema<>()
.type(TYPE_INTEGER)
.description("Timestamp when version was created in source system")
.nullable(true),
newSchema()
.types(TYPE_INTEGER_NULLABLE)
.description("Timestamp when version was created in source system"),
"sourceCreator",
new Schema<>()
.type(TYPE_STRING)
.description("Creator of version in source system")
.nullable(true)));
newSchema()
.types(TYPE_STRING_NULLABLE)
.description("Creator of version in source system")));
}
private static PathItem buildVersioningRelationshipPath() {
@ -1437,13 +1427,13 @@ public class OpenAPIV3Generator {
.name("versionSetUrn")
.description("The Version Set URN to unlink from")
.required(true)
.schema(new Schema().type(TYPE_STRING)),
.schema(newSchema().type(TYPE_STRING)),
new Parameter()
.in(NAME_PATH)
.name("entityUrn")
.description("The Entity URN to be unlinked")
.required(true)
.schema(new Schema().type(TYPE_STRING)));
.schema(newSchema().type(TYPE_STRING)));
// Success response for DELETE
final ApiResponse successDeleteResponse =
@ -1474,7 +1464,7 @@ public class OpenAPIV3Generator {
"application/json",
new MediaType()
.schema(
new Schema<>()
newSchema()
.$ref(
String.format(
"#/components/schemas/%s%s",
@ -1509,6 +1499,31 @@ public class OpenAPIV3Generator {
return result.delete(deleteOperation).post(postOperation);
}
private static Schema<?> buildAspectPatchPropertySchema() {
Schema<?> schema = new Schema<>();
schema.type(TYPE_OBJECT);
schema.required(List.of(PROPERTY_VALUE));
schema.addProperty(
PROPERTY_VALUE,
new Schema<>()
.$ref(PATH_DEFINITIONS + ASPECT_PATCH)
.description("Patch to apply to the aspect."));
schema.addProperty(
NAME_SYSTEM_METADATA,
newSchema()
.types(TYPE_OBJECT_NULLABLE)
.$ref(PATH_DEFINITIONS + "SystemMetadata")
.description("System metadata for the aspect."));
schema.addProperty(
"headers",
new Schema<>()
.types(Set.of(TYPE_OBJECT, "nullable"))
.nullable(true)
.additionalProperties(new Schema<>().type(TYPE_STRING))
.description("System headers for the operation."));
return schema;
}
private static Map<String, EntitySpec> getEntitySpecs(@Nonnull EntityRegistry entityRegistry) {
return entityRegistry.getEntitySpecs().entrySet().stream()
.filter(
@ -1532,4 +1547,21 @@ public class OpenAPIV3Generator {
Collectors.toMap(
Map.Entry::getKey, Map.Entry::getValue, (existing, replacement) -> existing)));
}
private static Schema newSchema() {
return new Schema().specVersion(SPEC_VERSION);
}
private static Schema nullableSchema(Schema origin, Set<String> types) {
if (origin == null) {
return newSchema().types(types);
}
String nonNullType = types.stream().filter(t -> !"null".equals(t)).findFirst().orElse(null);
origin.setType(nonNullType);
origin.setTypes(types);
return origin;
}
}

View File

@ -1,36 +1,49 @@
package io.datahubproject.openapi.config;
import static org.mockito.Mockito.mock;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertTrue;
import static org.testng.AssertJUnit.assertEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.EntityRegistry;
import com.linkedin.metadata.spring.YamlPropertySourceFactory;
import io.datahubproject.metadata.context.OperationContext;
import io.datahubproject.openapi.v3.OpenAPIV3Generator;
import io.datahubproject.test.metadata.context.TestOperationContexts;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.SpecVersion;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.media.Schema;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springdoc.core.providers.ObjectMapperProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.PropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.testng.AbstractTestNGSpringContextTests;
import org.testng.annotations.Test;
@Import(ConfigurationProvider.class)
@PropertySource(value = "classpath:/application.yaml", factory = YamlPropertySourceFactory.class)
@SpringBootTest(classes = {ConfigurationProvider.class, SpringWebConfig.class})
@Import({SpringWebConfigTestConfiguration.class})
@TestPropertySource(locations = "classpath:/application.yaml")
public class SpringWebConfigTest extends AbstractTestNGSpringContextTests {
OperationContext operationContext = TestOperationContexts.systemContextNoSearchAuthorization();
@Autowired private ConfigurationProvider configurationProvider;
@Autowired private ObjectMapperProvider objectMapperProvider;
@Autowired private List<OpenAPI> openAPIs;
@Test
void testComponentsMergeWithDuplicateKeys() {
// Setup
SpringWebConfig config = new SpringWebConfig();
EntityRegistry entityRegistry = mock(EntityRegistry.class);
// Create test schemas with duplicate keys
Map<String, Schema> schemas1 = new HashMap<>();
@ -39,16 +52,25 @@ public class SpringWebConfigTest extends AbstractTestNGSpringContextTests {
Map<String, Schema> schemas2 = new HashMap<>();
schemas2.put("TestSchema", new Schema().type("object").description("Second schema"));
// Create OpenAPI objects with proper initialization
SpecVersion specVersion = SpecVersion.V31; // Use the same version as in OpenAPIV3Generator
OpenAPI openApi1 =
new OpenAPI().components(new Components().schemas(schemas1)).paths(new Paths());
new OpenAPI(specVersion)
.components(new Components().schemas(schemas1))
.paths(new Paths())
.info(new Info().title("Test API").version("1.0"));
OpenAPI openApi2 =
new OpenAPI().components(new Components().schemas(schemas2)).paths(new Paths());
new OpenAPI(specVersion)
.components(new Components().schemas(schemas2))
.paths(new Paths())
.info(new Info().title("Test API").version("2.0"));
// Mock OpenAPIV3Generator
try (MockedStatic<OpenAPIV3Generator> mockedGenerator =
Mockito.mockStatic(OpenAPIV3Generator.class)) {
// Set up the mock BEFORE calling the method under test
mockedGenerator
.when(
() ->
@ -56,11 +78,16 @@ public class SpringWebConfigTest extends AbstractTestNGSpringContextTests {
Mockito.any(EntityRegistry.class), Mockito.any(ConfigurationProvider.class)))
.thenReturn(openApi2);
// Get the GroupedOpenApi
var groupedApi = config.v3OpenApiGroup(entityRegistry, configurationProvider);
// Get the GroupedOpenApi - this will call generateOpenApiSpec internally
var groupedApi =
config.v3OpenApiGroup(operationContext.getEntityRegistry(), configurationProvider);
// Execute the customizer
groupedApi.getOpenApiCustomizers().forEach(c -> c.customise(openApi1));
// The customizer is what actually performs the merge
// We need to simulate what Spring does when it builds the OpenAPI spec
assertEquals(1, groupedApi.getOpenApiCustomizers().size());
// Apply the customizer to openApi1 (simulating Spring's behavior)
groupedApi.getOpenApiCustomizers().forEach(customizer -> customizer.customise(openApi1));
// Verify the merged components
Map<String, Schema> mergedSchemas = openApi1.getComponents().getSchemas();
@ -68,10 +95,42 @@ public class SpringWebConfigTest extends AbstractTestNGSpringContextTests {
// Assert that we have the expected number of schemas
assertEquals(1, mergedSchemas.size());
// Assert that the duplicate key contains the second schema (v2 value)
// Assert that the duplicate key contains the second schema (registry/v3 value takes
// precedence)
Schema resultSchema = mergedSchemas.get("TestSchema");
assertNotNull(resultSchema);
assertEquals("object", resultSchema.getType());
assertEquals("Second schema", resultSchema.getDescription());
// Verify that info was also merged from the registry OpenAPI
assertNotNull(openApi1.getInfo());
assertEquals("Test API", openApi1.getInfo().getTitle());
assertEquals("2.0", openApi1.getInfo().getVersion());
}
}
@Test
void testV31EnumSerialization() throws JsonProcessingException {
assertNotNull(openAPIs);
List<OpenAPI> withSchema =
openAPIs.stream()
.filter(o -> o.getComponents().getSchemas().containsKey("VersioningScheme"))
.collect(Collectors.toUnmodifiableList());
assertEquals(withSchema.size(), 1);
OpenAPI openAPI = withSchema.get(0);
assertEquals(openAPI.getOpenapi(), "3.1.0");
Schema schema = openAPI.getComponents().getSchemas().get("VersioningScheme");
assertEquals("string", schema.getType());
// Use SpringDoc's mapper, not Json.mapper()
String json =
objectMapperProvider
.jsonMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(openAPI);
assertTrue(json.contains("\"VersioningScheme\" : {\n \"type\" : \"string\""));
assertTrue(json.contains("\"openapi\" : \"3.1.0\""));
}
}

View File

@ -0,0 +1,54 @@
package io.datahubproject.openapi.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.EntityRegistry;
import io.datahubproject.metadata.context.OperationContext;
import io.datahubproject.openapi.v3.OpenAPIV3Generator;
import io.datahubproject.test.metadata.context.TestOperationContexts;
import io.swagger.v3.oas.models.OpenAPI;
import org.springdoc.core.properties.SpringDocConfigProperties;
import org.springdoc.core.providers.ObjectMapperProvider;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
@EnableWebMvc
@TestConfiguration
public class SpringWebConfigTestConfiguration {
@MockBean TracingInterceptor tracingInterceptor;
@Bean
public OperationContext systemOperationContext() {
return TestOperationContexts.systemContextNoSearchAuthorization();
}
@Bean
public EntityRegistry entityRegistry(final OperationContext systemOperationContext) {
return systemOperationContext.getEntityRegistry();
}
@Bean
public SpringDocConfigProperties springDocConfigProperties() {
return new SpringDocConfigProperties();
}
@Bean
public ObjectMapperProvider objectMapperProvider(
SpringDocConfigProperties springDocConfigProperties) {
return new ObjectMapperProvider(springDocConfigProperties) {
@Override
public ObjectMapper jsonMapper() {
ObjectMapper mapper = super.jsonMapper();
return mapper;
}
};
}
@Bean
public OpenAPI openAPIs(
final EntityRegistry entityRegistry, final ConfigurationProvider configurationProvider) {
return OpenAPIV3Generator.generateOpenApiSpec(entityRegistry, configurationProvider);
}
}

View File

@ -0,0 +1,371 @@
package io.datahubproject.openapi.v3;
import static org.mockito.Mockito.*;
import static org.testng.Assert.*;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.EntityRegistry;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.SpecVersion;
import io.swagger.v3.oas.models.callbacks.Callback;
import io.swagger.v3.oas.models.examples.Example;
import io.swagger.v3.oas.models.headers.Header;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.links.Link;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.parameters.RequestBody;
import io.swagger.v3.oas.models.responses.ApiResponse;
import io.swagger.v3.oas.models.security.SecurityScheme;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class OpenAPIV3CustomizerTest {
private EntityRegistry mockEntityRegistry;
private ConfigurationProvider mockConfigurationProvider;
private SpecVersion specVersion;
@BeforeMethod
public void setUp() {
mockEntityRegistry = mock(EntityRegistry.class);
mockConfigurationProvider = mock(ConfigurationProvider.class);
specVersion = SpecVersion.V31;
}
@Test
public void testCustomizerMergesComponentsCorrectly() {
// Setup Spring OpenAPI with existing components
Map<String, Schema> springSchemas = new HashMap<>();
springSchemas.put("ExistingSchema", new Schema().type("string").description("Spring schema"));
springSchemas.put(
"OverrideSchema", new Schema().type("integer").description("Spring override"));
Components springComponents = new Components();
springComponents.setSchemas(springSchemas);
OpenAPI springOpenAPI =
new OpenAPI(specVersion)
.components(springComponents)
.paths(new Paths())
.info(new Info().title("Spring API").version("1.0"));
// Setup Registry OpenAPI that will be returned by generateOpenApiSpec
Map<String, Schema> registrySchemas = new HashMap<>();
registrySchemas.put("NewSchema", new Schema().type("boolean").description("Registry schema"));
registrySchemas.put(
"OverrideSchema", new Schema().type("object").description("Registry override"));
Components registryComponents = new Components();
registryComponents.setSchemas(registrySchemas);
OpenAPI registryOpenAPI =
new OpenAPI(specVersion)
.components(registryComponents)
.paths(new Paths())
.info(new Info().title("Registry API").version("2.0"));
// Mock the static method
try (MockedStatic<OpenAPIV3Generator> mockedGenerator =
Mockito.mockStatic(OpenAPIV3Generator.class)) {
mockedGenerator
.when(
() ->
OpenAPIV3Generator.generateOpenApiSpec(
any(EntityRegistry.class), any(ConfigurationProvider.class)))
.thenReturn(registryOpenAPI);
// Execute customizer
OpenAPIV3Customizer.customizer(springOpenAPI, mockEntityRegistry, mockConfigurationProvider);
// Verify results
assertNotNull(springOpenAPI.getComponents());
Map<String, Schema> mergedSchemas = springOpenAPI.getComponents().getSchemas();
assertNotNull(mergedSchemas);
// Should have 3 schemas: ExistingSchema, NewSchema, and OverrideSchema
assertEquals(3, mergedSchemas.size());
// ExistingSchema should remain from Spring
assertTrue(mergedSchemas.containsKey("ExistingSchema"));
assertEquals("string", mergedSchemas.get("ExistingSchema").getType());
assertEquals("Spring schema", mergedSchemas.get("ExistingSchema").getDescription());
// NewSchema should be added from Registry
assertTrue(mergedSchemas.containsKey("NewSchema"));
assertEquals("boolean", mergedSchemas.get("NewSchema").getType());
assertEquals("Registry schema", mergedSchemas.get("NewSchema").getDescription());
// OverrideSchema should be overridden by Registry
assertTrue(mergedSchemas.containsKey("OverrideSchema"));
assertEquals("object", mergedSchemas.get("OverrideSchema").getType());
assertEquals("Registry override", mergedSchemas.get("OverrideSchema").getDescription());
// Info should be replaced with Registry info
assertEquals("Registry API", springOpenAPI.getInfo().getTitle());
assertEquals("2.0", springOpenAPI.getInfo().getVersion());
}
}
@Test
public void testCustomizerWithNullComponents() {
// Spring OpenAPI without components
OpenAPI springOpenAPI =
new OpenAPI(specVersion)
.paths(new Paths())
.info(new Info().title("Spring API").version("1.0"));
// Registry OpenAPI with components
Map<String, Schema> registrySchemas = new HashMap<>();
registrySchemas.put("RegistrySchema", new Schema().type("string"));
Components registryComponents = new Components();
registryComponents.setSchemas(registrySchemas);
OpenAPI registryOpenAPI =
new OpenAPI(specVersion)
.components(registryComponents)
.paths(new Paths())
.info(new Info().title("Registry API").version("2.0"));
try (MockedStatic<OpenAPIV3Generator> mockedGenerator =
Mockito.mockStatic(OpenAPIV3Generator.class)) {
mockedGenerator
.when(
() ->
OpenAPIV3Generator.generateOpenApiSpec(
any(EntityRegistry.class), any(ConfigurationProvider.class)))
.thenReturn(registryOpenAPI);
// Execute customizer
OpenAPIV3Customizer.customizer(springOpenAPI, mockEntityRegistry, mockConfigurationProvider);
// Verify results
assertNotNull(springOpenAPI.getComponents());
Map<String, Schema> schemas = springOpenAPI.getComponents().getSchemas();
assertNotNull(schemas);
assertEquals(1, schemas.size());
assertTrue(schemas.containsKey("RegistrySchema"));
}
}
@Test
public void testCustomizerMergesAllComponentTypes() {
// Setup comprehensive Spring components
Components springComponents = new Components();
springComponents.setSchemas(Map.of("SpringSchema", new Schema().type("string")));
springComponents.setResponses(
Map.of("SpringResponse", new ApiResponse().description("Spring")));
springComponents.setParameters(Map.of("SpringParam", new Parameter().name("spring")));
springComponents.setExamples(Map.of("SpringExample", new Example().value("spring")));
springComponents.setRequestBodies(
Map.of("SpringBody", new RequestBody().description("Spring")));
springComponents.setHeaders(Map.of("SpringHeader", new Header().description("Spring")));
springComponents.setSecuritySchemes(
Map.of("SpringSecurity", new SecurityScheme().type(SecurityScheme.Type.HTTP)));
springComponents.setLinks(Map.of("SpringLink", new Link().operationId("spring")));
springComponents.setCallbacks(Map.of("SpringCallback", new Callback()));
OpenAPI springOpenAPI =
new OpenAPI(specVersion)
.components(springComponents)
.info(new Info().title("Spring API").version("1.0"));
// Setup comprehensive Registry components with some overlaps
Components registryComponents = new Components();
registryComponents.setSchemas(
Map.of(
"RegistrySchema", new Schema().type("boolean"),
"SpringSchema", new Schema().type("object"))); // Override
registryComponents.setResponses(
Map.of("RegistryResponse", new ApiResponse().description("Registry")));
registryComponents.setParameters(Map.of("RegistryParam", new Parameter().name("registry")));
OpenAPI registryOpenAPI =
new OpenAPI(specVersion)
.components(registryComponents)
.info(new Info().title("Registry API").version("2.0"));
try (MockedStatic<OpenAPIV3Generator> mockedGenerator =
Mockito.mockStatic(OpenAPIV3Generator.class)) {
mockedGenerator
.when(
() ->
OpenAPIV3Generator.generateOpenApiSpec(
any(EntityRegistry.class), any(ConfigurationProvider.class)))
.thenReturn(registryOpenAPI);
// Execute customizer
OpenAPIV3Customizer.customizer(springOpenAPI, mockEntityRegistry, mockConfigurationProvider);
// Verify all component types are merged correctly
Components merged = springOpenAPI.getComponents();
// Schemas
assertEquals(2, merged.getSchemas().size());
assertEquals("object", merged.getSchemas().get("SpringSchema").getType()); // Overridden
assertEquals("boolean", merged.getSchemas().get("RegistrySchema").getType());
// Responses
assertEquals(2, merged.getResponses().size());
assertTrue(merged.getResponses().containsKey("SpringResponse"));
assertTrue(merged.getResponses().containsKey("RegistryResponse"));
// Parameters
assertEquals(2, merged.getParameters().size());
assertTrue(merged.getParameters().containsKey("SpringParam"));
assertTrue(merged.getParameters().containsKey("RegistryParam"));
// Other components should remain from Spring only
assertEquals(1, merged.getExamples().size());
assertEquals(1, merged.getRequestBodies().size());
assertEquals(1, merged.getHeaders().size());
assertEquals(1, merged.getSecuritySchemes().size());
assertEquals(1, merged.getLinks().size());
assertEquals(1, merged.getCallbacks().size());
}
}
@Test
public void testCustomizerMergesPaths() {
// Spring OpenAPI with paths
Paths springPaths = new Paths();
springPaths.addPathItem("/spring", new PathItem().description("Spring path"));
OpenAPI springOpenAPI =
new OpenAPI(specVersion)
.paths(springPaths)
.info(new Info().title("Spring API").version("1.0"));
// Registry OpenAPI with paths
Paths registryPaths = new Paths();
registryPaths.addPathItem("/registry", new PathItem().description("Registry path"));
registryPaths.addPathItem(
"/spring", new PathItem().description("Registry override")); // Override
OpenAPI registryOpenAPI =
new OpenAPI(specVersion)
.paths(registryPaths)
.info(new Info().title("Registry API").version("2.0"));
try (MockedStatic<OpenAPIV3Generator> mockedGenerator =
Mockito.mockStatic(OpenAPIV3Generator.class)) {
mockedGenerator
.when(
() ->
OpenAPIV3Generator.generateOpenApiSpec(
any(EntityRegistry.class), any(ConfigurationProvider.class)))
.thenReturn(registryOpenAPI);
// Execute customizer
OpenAPIV3Customizer.customizer(springOpenAPI, mockEntityRegistry, mockConfigurationProvider);
// Verify paths are merged
Paths mergedPaths = springOpenAPI.getPaths();
assertNotNull(mergedPaths);
assertEquals(2, mergedPaths.size());
// Registry path should override Spring path
assertEquals("Registry override", mergedPaths.get("/spring").getDescription());
assertEquals("Registry path", mergedPaths.get("/registry").getDescription());
}
}
@Test
public void testMergeWithPrecedenceDirectly() throws Exception {
// Use reflection to test the private mergeWithPrecedence method
Method mergeMethod =
OpenAPIV3Customizer.class.getDeclaredMethod(
"mergeWithPrecedence", Supplier.class, Supplier.class);
mergeMethod.setAccessible(true);
// Test data
Map<String, String> springMap = new HashMap<>();
springMap.put("common", "spring-value");
springMap.put("spring-only", "spring-unique");
Map<String, String> registryMap = new HashMap<>();
registryMap.put("common", "registry-value");
registryMap.put("registry-only", "registry-unique");
@SuppressWarnings("unchecked")
Map<String, String> result =
(Map<String, String>)
mergeMethod.invoke(
null,
(Supplier<Map<String, String>>) () -> springMap,
(Supplier<Map<String, String>>) () -> registryMap);
// Verify merge behavior
assertNotNull(result);
assertEquals(3, result.size());
// Registry should override common keys
assertEquals("registry-value", result.get("common"));
// Both unique keys should be present
assertEquals("spring-unique", result.get("spring-only"));
assertEquals("registry-unique", result.get("registry-only"));
}
@Test
public void testMergeWithPrecedenceNullHandling() throws Exception {
Method mergeMethod =
OpenAPIV3Customizer.class.getDeclaredMethod(
"mergeWithPrecedence", Supplier.class, Supplier.class);
mergeMethod.setAccessible(true);
// Test with null spring map
Map<String, String> registryMap = Map.of("key", "value");
@SuppressWarnings("unchecked")
Map<String, String> result1 =
(Map<String, String>)
mergeMethod.invoke(
null,
(Supplier<Map<String, String>>) () -> null,
(Supplier<Map<String, String>>) () -> registryMap);
assertEquals(1, result1.size());
assertEquals("value", result1.get("key"));
// Test with null registry map
Map<String, String> springMap = Map.of("key", "value");
@SuppressWarnings("unchecked")
Map<String, String> result2 =
(Map<String, String>)
mergeMethod.invoke(
null,
(Supplier<Map<String, String>>) () -> springMap,
(Supplier<Map<String, String>>) () -> null);
assertEquals(1, result2.size());
assertEquals("value", result2.get("key"));
// Test with both null
@SuppressWarnings("unchecked")
Map<String, String> result3 =
(Map<String, String>)
mergeMethod.invoke(
null,
(Supplier<Map<String, String>>) () -> null,
(Supplier<Map<String, String>>) () -> null);
assertNotNull(result3);
assertTrue(result3.isEmpty());
}
}

View File

@ -2,38 +2,52 @@ package io.datahubproject.openapi.v3;
import static io.datahubproject.test.search.SearchTestUtils.TEST_ES_SEARCH_CONFIG;
import static io.datahubproject.test.search.SearchTestUtils.TEST_SEARCH_SERVICE_CONFIG;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNotNull;
import static org.testng.Assert.assertNull;
import static org.testng.Assert.assertTrue;
import static org.testng.Assert.fail;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.linkedin.data.schema.annotation.PathSpecBasedSchemaAnnotationVisitor;
import com.linkedin.datahub.graphql.featureflags.FeatureFlags;
import com.linkedin.gms.factory.config.ConfigurationProvider;
import com.linkedin.metadata.models.registry.ConfigEntityRegistry;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import io.swagger.parser.OpenAPIParser;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.core.util.Yaml;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.parser.OpenAPIV3Parser;
import io.swagger.v3.parser.core.models.ParseOptions;
import io.swagger.v3.parser.core.models.SwaggerParseResult;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Stream;
import org.testng.annotations.BeforeTest;
import org.testng.annotations.Test;
public class OpenAPIV3GeneratorTest {
public static final String BASE_DIRECTORY =
"../../entity-registry/custom-test-model/build/plugins/models";
private OpenAPI openAPI;
@BeforeTest
@ -63,7 +77,7 @@ public class OpenAPIV3GeneratorTest {
// Parse and validate the OpenAPI spec
ParseOptions options = new ParseOptions();
options.setResolve(true);
options.setResolveFully(true);
options.setResolveFully(false); // requires a lot of memory
options.setValidateInternalRefs(true); // Important for schema validation
SwaggerParseResult result = new OpenAPIParser().readContents(openApiJson, null, options);
@ -83,6 +97,79 @@ public class OpenAPIV3GeneratorTest {
assertNotNull(result.getOpenAPI(), "Parsed OpenAPI should not be null");
}
@Test
void testIncrementalReferenceResolution() throws IOException {
// This test demonstrates resolving references incrementally
// useful for very large specs where you need to process piece by piece
File tempFile = File.createTempFile("openapi", ".json");
tempFile.deleteOnExit();
try (FileWriter writer = new FileWriter(tempFile)) {
Json.mapper().writeValue(writer, openAPI);
}
// Parse without resolution
ParseOptions options = new ParseOptions();
options.setResolve(false); // Don't resolve at all initially
OpenAPIV3Parser parser = new OpenAPIV3Parser();
OpenAPI parsedSpec = parser.read(tempFile.getAbsolutePath(), null, options);
assertNotNull(parsedSpec, "Should be able to parse without resolution");
// Count total references first
AtomicInteger totalRefs = new AtomicInteger(0);
countAllReferences(parsedSpec, totalRefs);
System.out.println("Total references found: " + totalRefs.get());
// Now resolve specific paths on demand
AtomicInteger resolvedCount = new AtomicInteger(0);
int maxResolutionsPerBatch = 10; // Process in small batches
// Load the JSON for reference validation
ObjectMapper mapper = new ObjectMapper();
JsonNode rootNode = mapper.readTree(tempFile);
if (parsedSpec.getPaths() != null) {
List<Map.Entry<String, PathItem>> pathEntries =
new ArrayList<>(parsedSpec.getPaths().entrySet());
for (int i = 0; i < pathEntries.size(); i += maxResolutionsPerBatch) {
int end = Math.min(i + maxResolutionsPerBatch, pathEntries.size());
List<Map.Entry<String, PathItem>> batch = pathEntries.subList(i, end);
// Resolve just this batch
resolveBatch(batch, rootNode, resolvedCount);
// In a real scenario, you might process/validate the batch here
// then allow it to be garbage collected before moving to the next batch
}
}
// Also check components for references
if (parsedSpec.getComponents() != null && parsedSpec.getComponents().getSchemas() != null) {
parsedSpec
.getComponents()
.getSchemas()
.forEach(
(name, schema) -> {
countSchemaReferences(schema, rootNode, resolvedCount);
});
}
System.out.println(
"Successfully resolved " + resolvedCount.get() + " references incrementally");
// Only assert if there were references to resolve
if (totalRefs.get() > 0) {
assertTrue(resolvedCount.get() > 0, "Should have resolved at least some references");
} else {
System.out.println("No references found in the spec to resolve");
}
}
@Test
public void testOpenAPICounts() throws IOException {
String openapiYaml = Yaml.pretty(openAPI);
@ -99,7 +186,7 @@ public class OpenAPIV3GeneratorTest {
public void testEnumString() {
// Assert enum property is string.
Schema fabricType = openAPI.getComponents().getSchemas().get("FabricType");
assertEquals("string", fabricType.getType());
assertEquals(fabricType.getType(), "string");
assertFalse(fabricType.getEnum().isEmpty());
}
@ -122,30 +209,35 @@ public class OpenAPIV3GeneratorTest {
@Test
public void testDatasetProperties() {
Schema datasetPropertiesSchema = openAPI.getComponents().getSchemas().get("DatasetProperties");
List<String> requiredNames = datasetPropertiesSchema.getRequired();
Map<String, Schema> properties = datasetPropertiesSchema.getProperties();
// Assert required properties are non-nullable
Schema customProperties = properties.get("customProperties");
assertFalse(requiredNames.contains("customProperties")); // not required due to default
assertFalse(
customProperties
.getNullable()); // it is however still not optional, therefore null is not allowed
assertNull(datasetPropertiesSchema.getRequired()); // not required due to defaults
assertNull(customProperties.getNullable());
assertEquals(customProperties.getType(), "object");
assertEquals(
customProperties.getTypes(),
Set.of("object")); // it is however still not optional, therefore null is not allowed
// Assert non-required properties are nullable
Schema name = properties.get("name");
assertFalse(requiredNames.contains("name"));
assertTrue(name.getNullable());
assertNull(name.getNullable());
assertEquals(name.getType(), "string");
assertEquals(name.getTypes(), Set.of("string", "null"));
// Assert non-required $ref properties are replaced by nullable { anyOf: [ $ref ] } objects
Schema created = properties.get("created");
assertFalse(requiredNames.contains("created"));
assertEquals("object", created.getType());
assertNull(created.getNullable());
assertNull(created.getType());
assertNull(created.getTypes());
assertNull(created.get$ref());
assertEquals(List.of(new Schema().$ref("#/components/schemas/TimeStamp")), created.getAnyOf());
assertTrue(created.getNullable());
assertEquals(
new HashSet<>(created.getAnyOf()),
Set.of(new Schema().$ref("#/components/schemas/TimeStamp"), new Schema<>().type("null")));
assertNull(created.getNullable());
// Assert systemMetadata property on response schema is optional.
// Assert systemMetadata property on response schema is optional per v3.1.0
Map<String, Schema> datasetPropertiesResponseSchemaProps =
openAPI
.getComponents()
@ -153,12 +245,9 @@ public class OpenAPIV3GeneratorTest {
.get("DatasetPropertiesAspectResponse_v3")
.getProperties();
Schema systemMetadata = datasetPropertiesResponseSchemaProps.get("systemMetadata");
assertEquals("object", systemMetadata.getType());
assertNull(systemMetadata.get$ref());
assertEquals(
List.of(new Schema().$ref("#/components/schemas/SystemMetadata")),
systemMetadata.getAnyOf());
assertTrue(systemMetadata.getNullable());
assertEquals(systemMetadata.getTypes(), Set.of("object", "null"));
assertEquals(systemMetadata.get$ref(), "#/components/schemas/SystemMetadata");
assertNull(systemMetadata.getNullable());
}
@Test
@ -169,20 +258,28 @@ public class OpenAPIV3GeneratorTest {
assertEquals(requiredNames, Set.of("title", "changeAuditStamps")); // required without optional
for (String reqProp : requiredNames) {
Schema customProperties = properties.get(reqProp);
assertFalse(customProperties.getNullable()); // null is not allowed
}
Schema titleSchema = properties.get("title");
assertNull(titleSchema.getNullable());
assertEquals(titleSchema.getTypes(), Set.of("string")); // null is not allowed
assertEquals(titleSchema.getType(), "string");
Schema changeAuditStamps = properties.get("changeAuditStamps");
assertEquals(changeAuditStamps.get$ref(), "#/components/schemas/ChangeAuditStamps");
Schema changeAuditStampsSchema = properties.get("changeAuditStamps");
assertNull(changeAuditStampsSchema.getNullable());
assertNull(changeAuditStampsSchema.getTypes());
assertNull(changeAuditStampsSchema.getType());
assertNull(changeAuditStampsSchema.getAnyOf()); // null is not allowed
assertEquals(changeAuditStampsSchema.get$ref(), "#/components/schemas/ChangeAuditStamps");
}
@Test
public void testChangeAuditStamps() {
Schema schema = openAPI.getComponents().getSchemas().get("ChangeAuditStamps");
List<String> requiredNames = schema.getRequired();
assertEquals(requiredNames, List.of());
assertNull(requiredNames);
// Assert types
assertEquals(schema.getTypes(), Set.of("object"));
assertEquals(schema.getType(), "object");
Map<String, Schema> properties = schema.getProperties();
assertEquals(properties.keySet(), Set.of("created", "lastModified", "deleted"));
@ -190,12 +287,26 @@ public class OpenAPIV3GeneratorTest {
Set.of("created", "lastModified")
.forEach(
prop -> {
assertFalse(properties.get(prop).getNullable());
assertNull(properties.get(prop).getNullable());
assertNull(properties.get(prop).getType());
assertNull(properties.get(prop).getTypes());
assertEquals(properties.get(prop).get$ref(), "#/components/schemas/AuditStamp");
});
Set.of("deleted")
.forEach(
prop -> {
assertTrue(properties.get(prop).getNullable());
assertNull(properties.get(prop).getNullable());
assertNull(properties.get(prop).getType());
assertNull(properties.get(prop).getTypes());
assertNull(properties.get(prop).get$ref());
assertEquals(properties.get(prop).getAnyOf().size(), 2);
assertTrue(
properties.get(prop).getAnyOf().stream()
.anyMatch(
s -> ((Schema) s).get$ref().equals("#/components/schemas/AuditStamp")));
assertTrue(
properties.get(prop).getAnyOf().stream()
.anyMatch(s -> ((Schema) s).get$ref() == null));
});
}
@ -224,4 +335,591 @@ public class OpenAPIV3GeneratorTest {
.getSchemas()
.containsKey("DataHubOpenAPISchemaKeyAspectResponse_v3"));
}
@Test
public void testNullableRefs() {
Schema dataProcessInstanceProperties =
openAPI.getComponents().getSchemas().get("DataProcessInstanceProperties");
Map<String, Schema> properties = dataProcessInstanceProperties.getProperties();
Schema optionalTypeSchema = properties.get("type");
assertNull(optionalTypeSchema.getNullable());
assertNull(optionalTypeSchema.getType());
assertNull(optionalTypeSchema.getTypes());
assertEquals(optionalTypeSchema.getAnyOf().size(), 2);
assertTrue(
optionalTypeSchema.getAnyOf().stream()
.anyMatch(s -> ((Schema) s).get$ref().equals("#/components/schemas/DataProcessType")));
assertTrue(
optionalTypeSchema.getAnyOf().stream().anyMatch(s -> ((Schema) s).get$ref() == null));
}
@Test
public void testV31Enums() {
Schema schema = openAPI.getComponents().getSchemas().get("VersioningScheme");
assertEquals(schema.getType(), "string");
assertTrue(
Json.pretty(openAPI).contains("\"VersioningScheme\" : {\n \"type\" : \"string\""));
}
@Test
void testOpenAPISchemaCompliance() throws IOException {
// This test validates the OpenAPI spec against the official OpenAPI JSON Schema
// Convert OpenAPI to JSON
ObjectMapper mapper = Json.mapper();
JsonNode openApiNode = mapper.valueToTree(openAPI);
// Determine OpenAPI version
String openApiVersion = openApiNode.path("openapi").asText();
assertNotNull(openApiVersion, "OpenAPI version must be specified");
// Load the appropriate schema based on version
JsonSchema schema;
JsonSchemaFactory schemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V7);
try {
if (openApiVersion.startsWith("3.1")) {
schema = loadOpenAPI31Schema(schemaFactory);
} else {
fail("Unsupported OpenAPI version: " + openApiVersion);
return;
}
} catch (Exception e) {
// If we can't load the schema, skip the test with a warning
System.err.println(
"WARNING: Could not load OpenAPI schema for validation: " + e.getMessage());
System.err.println(
"Skipping schema compliance test. Consider adding the schema file to your test resources.");
return;
}
// Validate the spec against the schema
Set<ValidationMessage> validationMessages = schema.validate(openApiNode);
// Log any validation errors
if (!validationMessages.isEmpty()) {
System.out.println("OpenAPI Schema Validation Errors:");
validationMessages.forEach(
msg -> {
System.out.println(
" - " + msg.getType() + ": " + msg.getMessage() + " at " + msg.getEvaluationPath());
});
}
// Assert no validation errors
assertTrue(
validationMessages.isEmpty(),
"OpenAPI spec should be compliant with OpenAPI "
+ openApiVersion
+ " schema. Found "
+ validationMessages.size()
+ " validation errors.");
}
@Test
void testOpenAPISchemaComplianceWithDetails() throws IOException {
// Enhanced version that provides more detailed validation information
ObjectMapper mapper = Json.mapper();
JsonNode openApiNode = mapper.valueToTree(openAPI);
// Basic structure validation
assertTrue(openApiNode.has("openapi"), "Must have 'openapi' field");
assertTrue(openApiNode.has("info"), "Must have 'info' field");
assertTrue(openApiNode.has("paths"), "Must have 'paths' field");
// Validate info section
JsonNode info = openApiNode.get("info");
assertTrue(info.has("title"), "Info must have 'title'");
assertTrue(info.has("version"), "Info must have 'version'");
// Validate paths
JsonNode paths = openApiNode.get("paths");
assertTrue(paths.isObject(), "Paths must be an object");
// Check each path
Iterator<Map.Entry<String, JsonNode>> pathIterator = paths.fields();
while (pathIterator.hasNext()) {
Map.Entry<String, JsonNode> pathEntry = pathIterator.next();
String path = pathEntry.getKey();
JsonNode pathItem = pathEntry.getValue();
// Validate path format
assertTrue(path.startsWith("/"), "Path must start with '/': " + path);
// Check for valid HTTP methods
Set<String> validMethods =
Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
Iterator<String> fieldNames = pathItem.fieldNames();
while (fieldNames.hasNext()) {
String field = fieldNames.next();
if (!field.startsWith("$")
&& !field.equals("summary")
&& !field.equals("description")
&& !field.equals("servers")
&& !field.equals("parameters")) {
assertTrue(
validMethods.contains(field.toLowerCase()),
"Invalid method '" + field + "' in path: " + path);
}
}
}
// If components exist, validate them
if (openApiNode.has("components")) {
JsonNode components = openApiNode.get("components");
validateComponents(components);
}
}
@Test
void testOpenAPISpecificConstraints() {
// Test specific OpenAPI constraints that might not be caught by JSON Schema
// Check operation IDs are unique
Set<String> operationIds = new HashSet<>();
if (openAPI.getPaths() != null) {
openAPI
.getPaths()
.forEach(
(path, pathItem) -> {
Stream.of(
pathItem.getGet(),
pathItem.getPost(),
pathItem.getPut(),
pathItem.getDelete(),
pathItem.getPatch(),
pathItem.getOptions(),
pathItem.getHead(),
pathItem.getTrace())
.filter(Objects::nonNull)
.forEach(
operation -> {
if (operation.getOperationId() != null) {
assertTrue(
operationIds.add(operation.getOperationId()),
"Duplicate operationId found: " + operation.getOperationId());
}
});
});
}
// Check that all tags used in operations are defined
Set<String> usedTags = new HashSet<>();
if (openAPI.getPaths() != null) {
openAPI
.getPaths()
.forEach(
(path, pathItem) -> {
Stream.of(
pathItem.getGet(),
pathItem.getPost(),
pathItem.getPut(),
pathItem.getDelete(),
pathItem.getPatch(),
pathItem.getOptions(),
pathItem.getHead(),
pathItem.getTrace())
.filter(Objects::nonNull)
.forEach(
operation -> {
if (operation.getTags() != null) {
usedTags.addAll(operation.getTags());
}
});
});
}
if (openAPI.getTags() != null) {
Set<String> definedTags = new HashSet<>();
openAPI.getTags().forEach(tag -> definedTags.add(tag.getName()));
usedTags.forEach(
tag -> {
assertTrue(
definedTags.contains(tag),
"Tag '" + tag + "' is used but not defined in the global tags section");
});
}
}
@Test
public void testAspectPatchPropertySchema() {
// Verify that AspectPatchProperty schema exists in components
assertNotNull(
openAPI.getComponents().getSchemas().get("AspectPatchProperty"),
"AspectPatchProperty schema should be present in components");
Schema aspectPatchPropertySchema =
openAPI.getComponents().getSchemas().get("AspectPatchProperty");
// Verify schema type
assertEquals(
aspectPatchPropertySchema.getType(),
"object",
"AspectPatchProperty should be of type object");
// Verify required fields
assertNotNull(
aspectPatchPropertySchema.getRequired(), "AspectPatchProperty should have required fields");
assertEquals(
aspectPatchPropertySchema.getRequired().size(),
1,
"AspectPatchProperty should have exactly 1 required field");
assertTrue(
aspectPatchPropertySchema.getRequired().contains("value"),
"AspectPatchProperty should require 'value' field");
// Verify properties
Map<String, Schema> properties = aspectPatchPropertySchema.getProperties();
assertNotNull(properties, "AspectPatchProperty should have properties");
assertEquals(properties.size(), 3, "AspectPatchProperty should have exactly 3 properties");
// Verify 'value' property
Schema valueProperty = properties.get("value");
assertNotNull(valueProperty, "AspectPatchProperty should have 'value' property");
assertEquals(
valueProperty.get$ref(),
"#/components/schemas/AspectPatch",
"Value property should reference AspectPatch schema");
assertEquals(
valueProperty.getDescription(),
"Patch to apply to the aspect.",
"Value property should have correct description");
// Verify 'systemMetadata' property
Schema systemMetadataProperty = properties.get("systemMetadata");
assertNotNull(
systemMetadataProperty, "AspectPatchProperty should have 'systemMetadata' property");
assertEquals(
systemMetadataProperty.get$ref(),
"#/components/schemas/SystemMetadata",
"SystemMetadata property should reference SystemMetadata schema");
assertEquals(
systemMetadataProperty.getTypes(),
Set.of("object", "null"),
"SystemMetadata property should allow object and null types");
assertEquals(
systemMetadataProperty.getDescription(),
"System metadata for the aspect.",
"SystemMetadata property should have correct description");
// Verify 'headers' property
Schema headersProperty = properties.get("headers");
assertNotNull(headersProperty, "AspectPatchProperty should have 'headers' property");
assertEquals(
headersProperty.getTypes(),
Set.of("object", "nullable"),
"Headers property should have correct types");
assertTrue(headersProperty.getNullable(), "Headers property should be nullable");
assertNotNull(
headersProperty.getAdditionalProperties(),
"Headers property should have additionalProperties");
assertEquals(
((Schema) headersProperty.getAdditionalProperties()).getType(),
"string",
"Headers additionalProperties should be of type string");
assertEquals(
headersProperty.getDescription(),
"System headers for the operation.",
"Headers property should have correct description");
}
@Test
public void testAspectPatchPropertySchemaUsageInAspectPatch() {
// Test that AspectPatchProperty is used in entity patch schemas
// Get a sample entity patch schema
Schema datasetPatchSchema =
openAPI.getComponents().getSchemas().get("DatasetEntityRequestPatch_v3");
assertNotNull(datasetPatchSchema, "Dataset patch schema should exist");
// Check that at least datasetProperties references AspectPatchProperty
Map<String, Schema> properties = datasetPatchSchema.getProperties();
Schema datasetProperties = properties.get("datasetProperties");
assertNotNull(datasetProperties, "Expected datasetProperties on dataset entity");
assertEquals(
datasetProperties.get$ref(),
"#/components/schemas/AspectPatchProperty",
"datasetProperties patch schemas should reference AspectPatchProperty for aspect properties");
}
private JsonSchema loadOpenAPI31Schema(JsonSchemaFactory schemaFactory) throws Exception {
URL schemaUrl = new URL("https://spec.openapis.org/oas/3.1/schema/2022-10-07");
return schemaFactory.getSchema(schemaUrl.openStream());
}
private void validateComponents(JsonNode components) {
// Validate schemas if present
if (components.has("schemas")) {
JsonNode schemas = components.get("schemas");
Iterator<Map.Entry<String, JsonNode>> schemaIterator = schemas.fields();
while (schemaIterator.hasNext()) {
Map.Entry<String, JsonNode> schemaEntry = schemaIterator.next();
String schemaName = schemaEntry.getKey();
JsonNode schema = schemaEntry.getValue();
// Check for valid schema properties
if (!schema.has("$ref")) {
// If not a reference, should have type or other schema keywords
boolean hasSchemaKeyword =
schema.has("type")
|| schema.has("properties")
|| schema.has("items")
|| schema.has("allOf")
|| schema.has("oneOf")
|| schema.has("anyOf")
|| schema.has("not");
assertTrue(
hasSchemaKeyword,
"Schema '" + schemaName + "' must have at least one schema keyword");
}
}
}
}
private void resolveBatch(
List<Map.Entry<String, PathItem>> batch, JsonNode rootNode, AtomicInteger resolvedCount) {
// This validates and counts references in a batch of path items
batch.forEach(
entry -> {
PathItem pathItem = entry.getValue();
// Check PathItem reference
if (pathItem.get$ref() != null) {
if (isReferenceValid(pathItem.get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
// Check operation references
Stream.of(
pathItem.getGet(),
pathItem.getPost(),
pathItem.getPut(),
pathItem.getDelete(),
pathItem.getPatch(),
pathItem.getOptions(),
pathItem.getHead(),
pathItem.getTrace())
.filter(Objects::nonNull)
.forEach(
operation -> {
// Check request body
if (operation.getRequestBody() != null
&& operation.getRequestBody().get$ref() != null) {
if (isReferenceValid(operation.getRequestBody().get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
// Check responses
if (operation.getResponses() != null) {
operation
.getResponses()
.forEach(
(code, response) -> {
if (response.get$ref() != null) {
if (isReferenceValid(response.get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
// Check response content
if (response.getContent() != null) {
response
.getContent()
.forEach(
(mediaType, content) -> {
if (content.getSchema() != null
&& content.getSchema().get$ref() != null) {
if (isReferenceValid(
content.getSchema().get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
});
}
});
}
// Check parameters
if (operation.getParameters() != null) {
operation
.getParameters()
.forEach(
param -> {
if (param.get$ref() != null) {
if (isReferenceValid(param.get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
if (param.getSchema() != null
&& param.getSchema().get$ref() != null) {
if (isReferenceValid(param.getSchema().get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
});
}
});
});
}
private boolean isReferenceValid(String ref, JsonNode rootNode) {
if (ref.startsWith("#/")) {
String path = ref.substring(2);
String[] parts = path.split("/");
JsonNode current = rootNode;
for (String part : parts) {
current = current.get(part);
if (current == null) {
return false;
}
}
return true;
}
// External references - for this test, consider them valid
return true;
}
private void countAllReferences(OpenAPI spec, AtomicInteger count) {
// Count references in paths
if (spec.getPaths() != null) {
spec.getPaths()
.forEach(
(path, pathItem) -> {
countPathItemReferences(pathItem, count);
});
}
// Count references in components
if (spec.getComponents() != null) {
if (spec.getComponents().getSchemas() != null) {
spec.getComponents()
.getSchemas()
.forEach(
(name, schema) -> {
countSchemaReferencesSimple(schema, count);
});
}
}
}
private void countPathItemReferences(PathItem pathItem, AtomicInteger count) {
if (pathItem.get$ref() != null) {
count.incrementAndGet();
}
Stream.of(
pathItem.getGet(),
pathItem.getPost(),
pathItem.getPut(),
pathItem.getDelete(),
pathItem.getPatch(),
pathItem.getOptions(),
pathItem.getHead(),
pathItem.getTrace())
.filter(Objects::nonNull)
.forEach(
operation -> {
if (operation.getRequestBody() != null
&& operation.getRequestBody().get$ref() != null) {
count.incrementAndGet();
}
if (operation.getResponses() != null) {
operation
.getResponses()
.forEach(
(code, response) -> {
if (response.get$ref() != null) {
count.incrementAndGet();
}
if (response.getContent() != null) {
response
.getContent()
.forEach(
(mediaType, content) -> {
if (content.getSchema() != null
&& content.getSchema().get$ref() != null) {
count.incrementAndGet();
}
});
}
});
}
if (operation.getParameters() != null) {
operation
.getParameters()
.forEach(
param -> {
if (param.get$ref() != null) {
count.incrementAndGet();
}
if (param.getSchema() != null && param.getSchema().get$ref() != null) {
count.incrementAndGet();
}
});
}
});
}
private void countSchemaReferencesSimple(Schema<?> schema, AtomicInteger count) {
if (schema.get$ref() != null) {
count.incrementAndGet();
}
if (schema.getProperties() != null) {
schema
.getProperties()
.forEach(
(propName, propSchema) -> {
countSchemaReferencesSimple(propSchema, count);
});
}
if (schema.getItems() != null) {
countSchemaReferencesSimple(schema.getItems(), count);
}
Stream.of(schema.getAllOf(), schema.getOneOf(), schema.getAnyOf())
.filter(Objects::nonNull)
.flatMap(List::stream)
.forEach(s -> countSchemaReferencesSimple(s, count));
}
private void countSchemaReferences(
Schema<?> schema, JsonNode rootNode, AtomicInteger resolvedCount) {
if (schema.get$ref() != null) {
if (isReferenceValid(schema.get$ref(), rootNode)) {
resolvedCount.incrementAndGet();
}
}
if (schema.getProperties() != null) {
schema
.getProperties()
.forEach(
(propName, propSchema) -> {
countSchemaReferences(propSchema, rootNode, resolvedCount);
});
}
if (schema.getItems() != null) {
countSchemaReferences(schema.getItems(), rootNode, resolvedCount);
}
Stream.of(schema.getAllOf(), schema.getOneOf(), schema.getAnyOf())
.filter(Objects::nonNull)
.flatMap(List::stream)
.forEach(s -> countSchemaReferences(s, rootNode, resolvedCount));
}
}

View File

@ -61,6 +61,10 @@ dependencies {
testRuntimeOnly externalDependency.logbackClassic
implementation externalDependency.charle
// Required for springdocs/swagger
implementation("javax.xml.bind:jaxb-api:2.3.1")
implementation("org.glassfish.jaxb:jaxb-runtime:2.3.8")
testImplementation externalDependency.testng
testImplementation externalDependency.springBootTest
testRuntimeOnly externalDependency.logbackClassic