feat(openapi): entity registry api (#13878)

This commit is contained in:
david-leifker 2025-06-27 13:32:29 -05:00 committed by GitHub
parent b162d6f365
commit ddb4e17772
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1481 additions and 4 deletions

View File

@ -7,7 +7,7 @@ import javax.annotation.Nonnull;
import lombok.Value;
@Value
public class UrnValidationFieldSpec {
public class UrnValidationFieldSpec implements FieldSpec {
@Nonnull PathSpec path;
@Nonnull UrnValidationAnnotation urnValidationAnnotation;
@Nonnull DataSchema pegasusSchema;

View File

@ -16,6 +16,8 @@ dependencies {
implementation externalDependency.opentelemetryApi
implementation externalDependency.opentelemetrySdkTrace
implementation externalDependency.opentelemetrySdkMetrics
api externalDependency.jacksonDataBind
api externalDependency.jacksonJDK8
compileOnly externalDependency.lombok
annotationProcessor externalDependency.lombok

View File

@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.linkedin.metadata.Constants;
import java.util.List;
import java.util.Optional;
@ -20,6 +21,7 @@ public class ObjectMapperContext implements ContextInterface {
static {
defaultMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
defaultMapper.registerModule(new Jdk8Module());
for (ObjectMapper mapper : List.of(defaultMapper, defaultYamlMapper)) {
int maxSize =

View File

@ -5,6 +5,7 @@ import static com.linkedin.metadata.Constants.MAX_JACKSON_STRING_SIZE;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -20,6 +21,7 @@ public class ObjectMapperFactory {
objectMapper
.getFactory()
.setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build());
objectMapper.registerModule(new Jdk8Module());
return objectMapper;
}
}

View File

@ -0,0 +1,14 @@
package io.datahubproject.openapi.v1.models.registry;
import java.util.Map;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AspectAnnotationDto {
private String name;
private boolean timeseries;
private boolean autoRender;
private Map<String, Object> renderSpec;
}

View File

@ -0,0 +1,118 @@
package io.datahubproject.openapi.v1.models.registry;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.linkedin.metadata.models.AspectSpec;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AspectSpecDto {
private AspectAnnotationDto aspectAnnotation;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> searchableFieldSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> searchableRefFieldSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> searchScoreFieldSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> relationshipFieldSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> timeseriesFieldSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> timeseriesFieldCollectionSpec;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
private Map<String, FieldSpecDto> urnValidationFieldSpec;
private String registryName;
private String registryVersion;
public static AspectSpecDto fromAspectSpec(AspectSpec aspectSpec) {
// Get the aspect annotation - need to use reflection since it's private
AspectAnnotationDto aspectAnnotationDto = null;
try {
// The AspectSpec class has getName(), isTimeseries(), isAutoRender(), getRenderSpec() methods
// that delegate to the AspectAnnotation
aspectAnnotationDto =
AspectAnnotationDto.builder()
.name(aspectSpec.getName())
.timeseries(aspectSpec.isTimeseries())
.autoRender(aspectSpec.isAutoRender())
.renderSpec(convertDataMap(aspectSpec.getRenderSpec()))
.build();
} catch (Exception e) {
log.error("Error extracting aspect annotation: {}", e.getMessage());
}
return AspectSpecDto.builder()
.aspectAnnotation(aspectAnnotationDto)
// Convert Maps using the generic approach
.searchableFieldSpec(convertFieldSpecMap(aspectSpec.getSearchableFieldSpecMap()))
.searchScoreFieldSpec(convertFieldSpecMap(aspectSpec.getSearchScoreFieldSpecMap()))
.relationshipFieldSpec(convertFieldSpecMap(aspectSpec.getRelationshipFieldSpecMap()))
.timeseriesFieldSpec(convertFieldSpecMap(aspectSpec.getTimeseriesFieldSpecMap()))
.timeseriesFieldCollectionSpec(
convertFieldSpecMap(aspectSpec.getTimeseriesFieldCollectionSpecMap()))
.searchableRefFieldSpec(
convertFieldSpecMapFromList(aspectSpec.getSearchableRefFieldSpecs()))
.urnValidationFieldSpec(convertFieldSpecMap(aspectSpec.getUrnValidationFieldSpecMap()))
.registryName(aspectSpec.getRegistryName())
.registryVersion(aspectSpec.getRegistryVersion().toString())
.build();
}
private static <T extends com.linkedin.metadata.models.FieldSpec>
Map<String, FieldSpecDto> convertFieldSpecMap(Map<String, T> fieldSpecMap) {
if (fieldSpecMap == null) {
return new HashMap<>();
}
Map<String, FieldSpecDto> result = new HashMap<>();
fieldSpecMap.forEach(
(key, value) -> {
FieldSpecDto converted = FieldSpecDto.fromFieldSpec(value);
if (converted != null) {
result.put(key, converted);
}
});
return result;
}
// Helper for searchableRefFieldSpecs which only has a List getter
private static Map<String, FieldSpecDto> convertFieldSpecMapFromList(
List<? extends com.linkedin.metadata.models.FieldSpec> fieldSpecs) {
if (fieldSpecs == null || fieldSpecs.isEmpty()) {
return null; // Return null instead of empty map
}
Map<String, FieldSpecDto> result = new HashMap<>();
for (com.linkedin.metadata.models.FieldSpec spec : fieldSpecs) {
FieldSpecDto converted = FieldSpecDto.fromFieldSpec(spec);
if (converted != null) {
result.put(spec.getPath().toString(), converted);
}
}
return result.isEmpty() ? null : result; // Return null if result is empty
}
private static Map<String, Object> convertDataMap(com.linkedin.data.DataMap dataMap) {
if (dataMap == null) {
return null;
}
Map<String, Object> result = new HashMap<>();
dataMap.forEach(result::put);
return result;
}
}

View File

@ -0,0 +1,23 @@
package io.datahubproject.openapi.v1.models.registry;
import com.linkedin.metadata.models.annotation.EntityAnnotation;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EntityAnnotationDto {
private String name;
private String keyAspect;
public static EntityAnnotationDto fromEntityAnnotation(EntityAnnotation annotation) {
if (annotation == null) {
return null;
}
return EntityAnnotationDto.builder()
.name(annotation.getName())
.keyAspect(annotation.getKeyAspect())
.build();
}
}

View File

@ -0,0 +1,112 @@
package io.datahubproject.openapi.v1.models.registry;
import com.linkedin.metadata.models.EntitySpec;
import com.linkedin.metadata.models.annotation.SearchableAnnotation;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EntitySpecDto {
private String name;
private EntityAnnotationDto entityAnnotation;
private String keyAspectName;
private AspectSpecDto keyAspectSpec;
private List<AspectSpecDto> aspectSpecs;
private Map<String, AspectSpecDto> aspectSpecMap;
private List<FieldSpecDto> searchableFieldSpecs;
private Map<String, Set<String>> searchableFieldTypes;
private List<FieldSpecDto> searchScoreFieldSpecs;
private List<FieldSpecDto> relationshipFieldSpecs;
private List<FieldSpecDto> searchableRefFieldSpecs;
// Schema information
private String snapshotSchemaName;
private String aspectTyperefSchemaName;
public static EntitySpecDto fromEntitySpec(EntitySpec entitySpec) {
if (entitySpec == null) {
return null;
}
return EntitySpecDto.builder()
.name(entitySpec.getName())
.entityAnnotation(
EntityAnnotationDto.fromEntityAnnotation(entitySpec.getEntityAnnotation()))
.keyAspectName(entitySpec.getKeyAspectName())
.keyAspectSpec(AspectSpecDto.fromAspectSpec(entitySpec.getKeyAspectSpec()))
.aspectSpecs(
entitySpec.getAspectSpecs().stream()
.map(AspectSpecDto::fromAspectSpec)
.collect(Collectors.toList()))
.aspectSpecMap(
entitySpec.getAspectSpecMap().entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey,
entry -> AspectSpecDto.fromAspectSpec(entry.getValue()))))
.searchableFieldSpecs(
entitySpec.getSearchableFieldSpecs().stream()
.map(FieldSpecDto::fromFieldSpec)
.collect(Collectors.toList()))
.searchableFieldTypes(convertSearchableFieldTypes(entitySpec.getSearchableFieldTypes()))
.searchScoreFieldSpecs(
entitySpec.getSearchScoreFieldSpecs().stream()
.map(FieldSpecDto::fromFieldSpec)
.collect(Collectors.toList()))
.relationshipFieldSpecs(
entitySpec.getRelationshipFieldSpecs().stream()
.map(FieldSpecDto::fromFieldSpec)
.collect(Collectors.toList()))
.searchableRefFieldSpecs(
entitySpec.getSearchableRefFieldSpecs().stream()
.map(FieldSpecDto::fromFieldSpec)
.collect(Collectors.toList()))
// Schema information - handle potential UnsupportedOperationException
.snapshotSchemaName(getSnapshotSchemaName(entitySpec))
.aspectTyperefSchemaName(getAspectTyperefSchemaName(entitySpec))
.build();
}
private static Map<String, Set<String>> convertSearchableFieldTypes(
Map<String, Set<SearchableAnnotation.FieldType>> fieldTypes) {
if (fieldTypes == null) {
return new HashMap<>();
}
return fieldTypes.entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream().map(Enum::name).collect(Collectors.toSet())));
}
private static String getSnapshotSchemaName(EntitySpec entitySpec) {
try {
return entitySpec.getSnapshotSchema() != null
? entitySpec.getSnapshotSchema().getFullName()
: null;
} catch (UnsupportedOperationException e) {
// Config-based entities don't have snapshot schemas
return null;
}
}
private static String getAspectTyperefSchemaName(EntitySpec entitySpec) {
try {
return entitySpec.getAspectTyperefSchema() != null
? entitySpec.getAspectTyperefSchema().getFullName()
: null;
} catch (UnsupportedOperationException e) {
// Config-based entities don't have aspect typeref schemas
return null;
}
}
}

View File

@ -0,0 +1,24 @@
package io.datahubproject.openapi.v1.models.registry;
import com.linkedin.metadata.models.EntitySpec;
import java.util.List;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EntitySpecResponse {
private String name;
private List<AspectSpecDto> aspectSpecs;
public static EntitySpecResponse fromEntitySpec(EntitySpec entitySpec) {
return EntitySpecResponse.builder()
.name(entitySpec.getName())
.aspectSpecs(
entitySpec.getAspectSpecs().stream()
.map(AspectSpecDto::fromAspectSpec)
.collect(Collectors.toList()))
.build();
}
}

View File

@ -0,0 +1,19 @@
package io.datahubproject.openapi.v1.models.registry;
import com.linkedin.metadata.models.annotation.EventAnnotation;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EventAnnotationDto {
private String name;
public static EventAnnotationDto fromEventAnnotation(EventAnnotation annotation) {
if (annotation == null) {
return null;
}
return EventAnnotationDto.builder().name(annotation.getName()).build();
}
}

View File

@ -0,0 +1,31 @@
package io.datahubproject.openapi.v1.models.registry;
import com.linkedin.metadata.models.EventSpec;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class EventSpecDto {
private String name;
private EventAnnotationDto eventAnnotation;
private String pegasusSchemaName;
private String pegasusSchemaDoc;
public static EventSpecDto fromEventSpec(EventSpec eventSpec) {
if (eventSpec == null) {
return null;
}
return EventSpecDto.builder()
.name(eventSpec.getName())
.eventAnnotation(EventAnnotationDto.fromEventAnnotation(eventSpec.getEventAnnotation()))
.pegasusSchemaName(
eventSpec.getPegasusSchema() != null
? eventSpec.getPegasusSchema().getFullName()
: null)
.pegasusSchemaDoc(
eventSpec.getPegasusSchema() != null ? eventSpec.getPegasusSchema().getDoc() : null)
.build();
}
}

View File

@ -0,0 +1,208 @@
package io.datahubproject.openapi.v1.models.registry;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import com.linkedin.data.schema.DataSchema;
import com.linkedin.metadata.models.*;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@Data
@Builder
public class FieldSpecDto {
private List<String> pathComponents;
private String fieldType;
@JsonSerialize(using = DataSchemaSerializer.class)
private DataSchema pegasusSchema;
private Map<String, Object> annotations; // All annotation data
private Map<String, Object> properties; // Other properties
public static class DataSchemaSerializer extends JsonSerializer<DataSchema> {
@Override
public void serialize(DataSchema value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
if (value != null) {
gen.writeRawValue(value.toString());
} else {
gen.writeNull();
}
}
}
public static FieldSpecDto fromFieldSpec(FieldSpec spec) {
if (spec == null) {
return null;
}
FieldSpecDtoBuilder builder =
FieldSpecDto.builder()
.pathComponents(spec.getPath().getPathComponents())
.fieldType(spec.getClass().getSimpleName())
.pegasusSchema(spec.getPegasusSchema());
Map<String, Object> annotations = new HashMap<>();
Map<String, Object> properties = new HashMap<>();
// Use reflection to find all getter methods
Method[] methods = spec.getClass().getMethods();
for (Method method : methods) {
String methodName = method.getName();
// Skip common interface methods we've already handled
if (methodName.equals("getPath")
|| methodName.equals("getPegasusSchema")
|| methodName.equals("getClass")
|| methodName.equals("hashCode")
|| methodName.equals("equals")
|| methodName.equals("toString")) {
continue;
}
// Process getter methods
if ((methodName.startsWith("get") || methodName.startsWith("is"))
&& method.getParameterCount() == 0) {
try {
Object value = method.invoke(spec);
if (value != null) {
String propertyName = extractPropertyName(methodName);
// Sanitize the value to prevent circular references
value = sanitizeValue(value);
// If it's an annotation object, serialize it separately
if (methodName.contains("Annotation")) {
annotations.put(propertyName, value);
} else {
properties.put(propertyName, value);
}
}
} catch (Exception e) {
// Log error but continue processing
System.err.println("Error processing method " + methodName + ": " + e.getMessage());
}
}
}
builder.annotations(annotations);
builder.properties(properties);
return builder.build();
}
private static String extractPropertyName(String methodName) {
if (methodName.startsWith("get")) {
return Character.toLowerCase(methodName.charAt(3)) + methodName.substring(4);
} else if (methodName.startsWith("is")) {
return Character.toLowerCase(methodName.charAt(2)) + methodName.substring(3);
}
return methodName;
}
private static Map<String, Object> serializeAnnotation(Object annotation) {
Map<String, Object> result = new HashMap<>();
// Use reflection to extract all properties from the annotation
Method[] methods = annotation.getClass().getMethods();
for (Method method : methods) {
String methodName = method.getName();
if ((methodName.startsWith("get") || methodName.startsWith("is"))
&& method.getParameterCount() == 0
&& !methodName.equals("getClass")
&& !methodName.equals("hashCode")
&& !methodName.equals("equals")
&& !methodName.equals("toString")) {
try {
Object value = method.invoke(annotation);
if (value != null) {
String propertyName = extractPropertyName(methodName);
// Sanitize the value to prevent circular references
value = sanitizeValue(value);
result.put(propertyName, value);
}
} catch (Exception e) {
// Log error but continue
log.error("Error processing annotation method {}: {}", methodName, e.getMessage());
}
}
}
return result;
}
/**
* Sanitizes values to prevent circular references and other serialization issues. This method
* handles special types that can cause problems during JSON serialization.
*/
private static Object sanitizeValue(Object value) {
if (value == null) {
return null;
}
// Handle PathSpec to avoid circular references
if (value instanceof com.linkedin.data.schema.PathSpec) {
com.linkedin.data.schema.PathSpec pathSpec = (com.linkedin.data.schema.PathSpec) value;
Map<String, Object> pathSpecMap = new HashMap<>();
pathSpecMap.put("pathComponents", pathSpec.getPathComponents());
// Explicitly don't include parent to avoid circular reference
return pathSpecMap;
}
// Handle FieldSpec objects (including TimeseriesFieldSpec, RelationshipFieldSpec, etc.)
if (value instanceof com.linkedin.metadata.models.FieldSpec) {
// Convert to FieldSpecDto to avoid circular references
return FieldSpecDto.fromFieldSpec((com.linkedin.metadata.models.FieldSpec) value);
}
// Handle DataMap
if (value instanceof com.linkedin.data.DataMap) {
Map<String, Object> dataMapCopy = new HashMap<>();
((com.linkedin.data.DataMap) value).forEach(dataMapCopy::put);
return dataMapCopy;
}
// Handle Maps recursively
if (value instanceof Map) {
Map<String, Object> sanitizedMap = new HashMap<>();
((Map<?, ?>) value)
.forEach(
(k, v) -> {
String key = k != null ? k.toString() : null;
if (key != null) {
sanitizedMap.put(key, sanitizeValue(v));
}
});
return sanitizedMap;
}
// Handle Collections recursively
if (value instanceof List) {
return ((List<?>) value)
.stream().map(FieldSpecDto::sanitizeValue).collect(java.util.stream.Collectors.toList());
}
// If it's an annotation object (contains "Annotation" in class name), serialize it
if (value.getClass().getName().contains("Annotation")) {
return serializeAnnotation(value);
}
// Return primitive types and strings as-is
return value;
}
}

View File

@ -0,0 +1,61 @@
package io.datahubproject.openapi.v1.models.registry;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
@JsonInclude(Include.NON_NULL)
public class PaginatedResponse<T> {
private List<T> elements;
private Integer start;
private Integer count;
private Integer total;
/** Creates a paginated response for DTOs that need custom mapping */
public static <K extends Comparable<K>, V, D> PaginatedResponse<D> fromMap(
Map<K, V> items,
Integer start,
Integer count,
java.util.function.Function<Map.Entry<K, V>, D> mapper) {
if (items == null || items.isEmpty()) {
return PaginatedResponse.<D>builder().elements(List.of()).start(0).count(0).total(0).build();
}
// Sort by key and map to DTOs
List<D> sortedItems =
items.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.map(mapper)
.collect(Collectors.toList());
// Apply pagination
int actualStart = start != null ? start : 0;
int actualCount = count != null ? count : sortedItems.size();
// Ensure start is within bounds
if (actualStart > (sortedItems.size() - 1)) {
throw new IllegalArgumentException(
String.format("Start offset %s exceeds total %s", actualStart, sortedItems.size() - 1));
}
// Calculate end index
int endIndex = Math.min(actualStart + actualCount, sortedItems.size());
// Get the page
List<D> pageItems = sortedItems.subList(actualStart, endIndex);
return PaginatedResponse.<D>builder()
.elements(pageItems)
.start(actualStart)
.count(pageItems.size())
.total(sortedItems.size())
.build();
}
}

View File

@ -0,0 +1,350 @@
package io.datahubproject.openapi.v1.registry;
import com.datahub.authentication.Authentication;
import com.datahub.authentication.AuthenticationContext;
import com.datahub.authorization.AuthUtil;
import com.datahub.authorization.AuthorizerChain;
import com.linkedin.metadata.authorization.PoliciesConfig;
import com.linkedin.metadata.models.AspectSpec;
import com.linkedin.metadata.models.EntitySpec;
import com.linkedin.metadata.models.EventSpec;
import io.datahubproject.metadata.context.OperationContext;
import io.datahubproject.metadata.context.RequestContext;
import io.datahubproject.openapi.v1.models.registry.AspectSpecDto;
import io.datahubproject.openapi.v1.models.registry.EntitySpecDto;
import io.datahubproject.openapi.v1.models.registry.EntitySpecResponse;
import io.datahubproject.openapi.v1.models.registry.EventSpecDto;
import io.datahubproject.openapi.v1.models.registry.PaginatedResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/openapi/v1/registry/models")
@Slf4j
@Tag(name = "Entity Registry API", description = "An API to expose the Entity Registry")
@AllArgsConstructor
@NoArgsConstructor
public class EntityRegistryController {
@Autowired private AuthorizerChain authorizerChain;
@Autowired private OperationContext systemOperationContext;
@GetMapping(path = "/entity/specifications", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description = "Retrieves all entity specs. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned entity specs",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<PaginatedResponse<EntitySpecDto>> getEntitySpecs(
HttpServletRequest request,
@Parameter(description = "Start index for pagination", example = "0")
@RequestParam(name = "start", required = false, defaultValue = "0")
Integer start,
@Parameter(name = "count", description = "Number of items to return", example = "5")
@RequestParam(name = "count", required = false, defaultValue = "5")
Integer count) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getEntitySpecs", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get entity", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
Map<String, EntitySpec> entitySpecs =
systemOperationContext.getEntityRegistry().getEntitySpecs();
PaginatedResponse<EntitySpecDto> response =
PaginatedResponse.fromMap(
entitySpecs,
start,
count,
entry -> {
EntitySpecDto dto = EntitySpecDto.fromEntitySpec(entry.getValue());
// Ensure the name is set from the map key
if (dto.getName() == null) {
dto.setName(entry.getKey());
}
return dto;
});
return ResponseEntity.ok(response);
}
@GetMapping(
path = "/entity/specifications/{entityName}",
produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description =
"Retrieves entity spec for entity. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned entity spec",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<EntitySpecResponse> getEntitySpec(
HttpServletRequest request, @PathVariable("entityName") String entityName) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getEntitySpec", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get entity", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
try {
EntitySpec entitySpec = systemOperationContext.getEntityRegistry().getEntitySpec(entityName);
return ResponseEntity.ok(EntitySpecResponse.fromEntitySpec(entitySpec));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null && e.getMessage().contains("Failed to find entity with name")) {
return ResponseEntity.notFound().build();
}
throw e;
}
}
@GetMapping(
path = "/entity/specifications/{entityName}/aspects",
produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description =
"Retrieves aspect specs for the entity. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned aspect specs",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<Map<String, AspectSpecDto>> getEntityAspecSpecs(
HttpServletRequest request, @PathVariable("entityName") String entityName) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getEntityAspecSpecs", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get entity model", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
try {
Map<String, AspectSpec> aspectSpecs =
systemOperationContext.getEntityRegistry().getEntitySpec(entityName).getAspectSpecMap();
Map<String, AspectSpecDto> aspectSpecDtos =
aspectSpecs.entrySet().stream()
.collect(
Collectors.toMap(
Map.Entry::getKey, entry -> AspectSpecDto.fromAspectSpec(entry.getValue())));
return ResponseEntity.ok(aspectSpecDtos);
} catch (IllegalArgumentException e) {
if (e.getMessage() != null && e.getMessage().contains("Failed to find entity with name")) {
return ResponseEntity.notFound().build();
}
throw e;
}
}
@GetMapping(path = "/aspect/specifications", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description = "Retrieves all entity specs. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned entity specs",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<PaginatedResponse<AspectSpecDto>> getAspectSpecs(
HttpServletRequest request,
@Parameter(description = "Start index for pagination", example = "0")
@RequestParam(name = "start", required = false, defaultValue = "0")
Integer start,
@Parameter(name = "count", description = "Number of items to return", example = "20")
@RequestParam(name = "count", required = false, defaultValue = "20")
Integer count) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getAspectSpecs", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get aspect models", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
Map<String, AspectSpec> aspectSpecs =
systemOperationContext.getEntityRegistry().getAspectSpecs();
PaginatedResponse<AspectSpecDto> response =
PaginatedResponse.fromMap(
aspectSpecs, start, count, entry -> AspectSpecDto.fromAspectSpec(entry.getValue()));
return ResponseEntity.ok(response);
}
@GetMapping(
path = "/aspect/specifications/{aspectName}",
produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description =
"Retrieves aspect spec for the specified aspect. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned aspect spec",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<AspectSpecDto> getAspectSpec(
HttpServletRequest request, @PathVariable("aspectName") String aspectName) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getAspectSpec", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get aspect models", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
if (systemOperationContext.getEntityRegistry().getAspectSpecs().get(aspectName) != null) {
return ResponseEntity.ok(
AspectSpecDto.fromAspectSpec(
systemOperationContext.getEntityRegistry().getAspectSpecs().get(aspectName)));
} else {
return ResponseEntity.notFound().build();
}
}
@GetMapping(path = "/event/specifications", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description = "Retrieves all event specs. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully returned event specs",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)),
@ApiResponse(
responseCode = "403",
description = "Caller not authorized to access the entity registry")
})
public ResponseEntity<PaginatedResponse<EventSpecDto>> getEventSpecs(
HttpServletRequest request,
@Parameter(description = "Start index for pagination", example = "0")
@RequestParam(name = "start", required = false, defaultValue = "0")
Integer start,
@Parameter(name = "count", description = "Number of items to return", example = "20")
@RequestParam(name = "count", required = false, defaultValue = "20")
Integer count) {
Authentication authentication = AuthenticationContext.getAuthentication();
String actorUrnStr = authentication.getActor().toUrnStr();
OperationContext opContext =
systemOperationContext.asSession(
RequestContext.builder()
.buildOpenapi(actorUrnStr, request, "getEventSpecs", Collections.emptyList()),
authorizerChain,
authentication);
if (!AuthUtil.isAPIOperationsAuthorized(
opContext, PoliciesConfig.MANAGE_SYSTEM_OPERATIONS_PRIVILEGE)) {
log.error("{} is not authorized to get event models", actorUrnStr);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(null);
}
Map<String, EventSpec> eventSpecs = systemOperationContext.getEntityRegistry().getEventSpecs();
// Use the PaginatedResponse helper to handle pagination and sorting
PaginatedResponse<EventSpecDto> response =
PaginatedResponse.fromMap(
eventSpecs,
start,
count,
entry -> {
EventSpecDto dto = EventSpecDto.fromEventSpec(entry.getValue());
// Ensure the name is set from the map key
if (dto.getName() == null) {
dto.setName(entry.getKey());
}
return dto;
});
return ResponseEntity.ok(response);
}
}

View File

@ -111,7 +111,7 @@ public class LineageRegistryController {
@GetMapping(path = "/edges/{entityName}", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description =
"Retrieves lineage lineage edges for entity. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
"Retrieves lineage edges for entity. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",
@ -156,7 +156,7 @@ public class LineageRegistryController {
@GetMapping(path = "/edges/{entityName}/{direction}", produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
description =
"Retrieves lineage lineage edges for entity in the provided direction. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
"Retrieves lineage edges for entity in the provided direction. Requires MANAGE_SYSTEM_OPERATIONS_PRIVILEGE.",
responses = {
@ApiResponse(
responseCode = "200",

View File

@ -0,0 +1,510 @@
package io.datahubproject.openapi.v1.registry;
import static io.datahubproject.test.metadata.context.TestOperationContexts.TEST_USER_AUTH;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import com.datahub.authentication.AuthenticationContext;
import com.datahub.authorization.AuthUtil;
import com.datahub.authorization.AuthorizerChain;
import com.linkedin.metadata.authorization.PoliciesConfig;
import io.datahubproject.metadata.context.OperationContext;
import io.datahubproject.test.metadata.context.TestOperationContexts;
import jakarta.servlet.ServletException;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.testng.annotations.AfterMethod;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
public class EntityRegistryControllerTest {
private MockMvc mockMvc;
private EntityRegistryController controller;
private AuthorizerChain mockAuthorizerChain;
private OperationContext operationContext;
private MockedStatic<AuthUtil> authUtilMock;
private MockedStatic<AuthenticationContext> authContextMock;
private static final String TEST_ENTITY_NAME = "dataset";
private static final String TEST_ASPECT_NAME = "datasetProperties";
private static final String NON_EXISTENT_ENTITY = "nonExistentEntity";
private static final String NON_EXISTENT_ASPECT = "nonExistentAspect";
@BeforeMethod
public void setup() {
// Create mocks
mockAuthorizerChain = mock(AuthorizerChain.class);
operationContext =
TestOperationContexts.userContextNoSearchAuthorization(mockAuthorizerChain, TEST_USER_AUTH);
authContextMock = Mockito.mockStatic(AuthenticationContext.class);
authContextMock.when(AuthenticationContext::getAuthentication).thenReturn(TEST_USER_AUTH);
// Create controller
controller = new EntityRegistryController(mockAuthorizerChain, operationContext);
// Setup MockMvc
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
// Mock AuthUtil static methods
authUtilMock = Mockito.mockStatic(AuthUtil.class);
}
@AfterMethod
public void tearDown() {
authUtilMock.close();
authContextMock.close();
}
@Test
public void testGetEntitySpecsWithAuthorizationDefaultPagination() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test - default pagination (start=0, count=20)
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", is(5)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(50)))
.andExpect(jsonPath("$.elements", hasSize(5)));
}
@Test
public void testGetEntitySpecsWithCustomPagination() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test with custom pagination
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications")
.param("start", "10")
.param("count", "5")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(10)))
.andExpect(jsonPath("$.count", is(5)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(50)))
.andExpect(jsonPath("$.elements", hasSize(5)));
}
@Test
public void testGetEntitySpecsWithLargeCount() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test with count larger than total
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications")
.param("start", "0")
.param("count", "100")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", greaterThanOrEqualTo(50)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(50)))
.andExpect(jsonPath("$.elements.length()", greaterThanOrEqualTo(50)));
}
@Test
public void testGetEntitySpecsUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
public void testGetEntitySpecByNameWithAuthorization() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications/{entityName}", TEST_ENTITY_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name", is(TEST_ENTITY_NAME)));
}
@Test
public void testGetEntitySpecByNameNotFound() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get(
"/openapi/v1/registry/models/entity/specifications/{entityName}",
NON_EXISTENT_ENTITY)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
public void testGetEntitySpecByNameUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/entity/specifications/{entityName}", TEST_ENTITY_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
public void testGetEntityAspectSpecsWithAuthorization() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get(
"/openapi/v1/registry/models/entity/specifications/{entityName}/aspects",
TEST_ENTITY_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$." + TEST_ASPECT_NAME).exists());
}
@Test
public void testGetEntityAspectSpecsNotFound() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get(
"/openapi/v1/registry/models/entity/specifications/{entityName}/aspects",
NON_EXISTENT_ENTITY)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
public void testGetEntityAspectSpecsUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get(
"/openapi/v1/registry/models/entity/specifications/{entityName}/aspects",
TEST_ENTITY_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
public void testGetAspectSpecsWithAuthorizationDefaultPagination() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", is(20)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(200)))
.andExpect(jsonPath("$.elements", hasSize(20)));
}
@Test
public void testGetAspectSpecsWithCustomPagination() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications")
.param("start", "5")
.param("count", "10")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(5)))
.andExpect(jsonPath("$.count", is(10)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(200)))
.andExpect(jsonPath("$.elements", hasSize(10)));
}
@Test
public void testGetAspectSpecsUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
public void testGetAspectSpecByNameWithAuthorization() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications/{aspectName}", TEST_ASPECT_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.aspectAnnotation.name", is(TEST_ASPECT_NAME)));
}
@Test
public void testGetAspectSpecByNameNotFound() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get(
"/openapi/v1/registry/models/aspect/specifications/{aspectName}",
NON_EXISTENT_ASPECT)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
public void testGetAspectSpecByNameUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications/{aspectName}", TEST_ASPECT_NAME)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test
public void testGetEventSpecsWithAuthorizationNoPagination() throws Exception {
// TODO: Revisit when there are events
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test - no pagination params should return all
mockMvc
.perform(
get("/openapi/v1/registry/models/event/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", is(0)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(0)))
.andExpect(jsonPath("$.elements.length()", greaterThanOrEqualTo(0)));
}
@Test
public void testGetEventSpecsWithPagination() throws Exception {
// TODO: Revisit when we have these specs
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/event/specifications")
.param("start", "2")
.param("count", "3")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", is(0)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(0)))
.andExpect(jsonPath("$.elements.length()", greaterThanOrEqualTo(0)));
}
@Test
public void testGetEventSpecsUnauthorized() throws Exception {
// Setup authorization to return false
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(false);
// Execute test
mockMvc
.perform(
get("/openapi/v1/registry/models/event/specifications")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isForbidden());
}
@Test(
expectedExceptions = ServletException.class,
expectedExceptionsMessageRegExp =
"Request processing failed: java.lang.IllegalArgumentException: Start offset 1000000000 exceeds total .*")
public void testPaginationWithStartBeyondTotal() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test with start beyond total items
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications")
.param("start", "1000000000")
.param("count", "20")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isBadRequest());
}
@Test
public void testPaginationWithZeroCount() throws Exception {
// Setup authorization to return true
authUtilMock
.when(
() ->
AuthUtil.isAPIOperationsAuthorized(
any(OperationContext.class), any(PoliciesConfig.Privilege.class)))
.thenReturn(true);
// Execute test with count = 0
mockMvc
.perform(
get("/openapi/v1/registry/models/aspect/specifications")
.param("start", "0")
.param("count", "0")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.start", is(0)))
.andExpect(jsonPath("$.count", is(0)))
.andExpect(jsonPath("$.total", greaterThanOrEqualTo(200)))
.andExpect(jsonPath("$.elements", hasSize(0)));
}
}

View File

@ -32,7 +32,6 @@ public class LineageRegistryControllerTest {
private MockedStatic<AuthenticationContext> authContextMock;
private static final String TEST_ENTITY_NAME = "dataset";
private static final String TEST_DOWNSTREAM_ENTITY = "chart";
@BeforeMethod
public void setup() {

View File

@ -14,6 +14,7 @@ import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.linkedin.r2.transport.http.server.RAPJakartaServlet;
import com.linkedin.restli.server.RestliHandlerServlet;
import io.datahubproject.iceberg.catalog.rest.common.IcebergJsonConverter;
@ -148,6 +149,7 @@ public class ServletConfig implements WebMvcConfigurer {
.setStreamReadConstraints(StreamReadConstraints.builder().maxStringLength(maxSize).build());
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
objectMapper.registerModule(new Jdk8Module());
MappingJackson2HttpMessageConverter jsonConverter =
new MappingJackson2HttpMessageConverter(objectMapper);
messageConverters.add(jsonConverter);