mirror of
https://github.com/datahub-project/datahub.git
synced 2025-12-03 14:16:28 +00:00
feat(openapi-31): properly update openapi spec to 3.1.0 (#13828)
This commit is contained in:
parent
889441217b
commit
e9103f1851
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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\""));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user