diff --git a/entity-registry/src/main/java/com/linkedin/metadata/models/UrnValidationFieldSpec.java b/entity-registry/src/main/java/com/linkedin/metadata/models/UrnValidationFieldSpec.java index b4bba0a8e8..1531209e98 100644 --- a/entity-registry/src/main/java/com/linkedin/metadata/models/UrnValidationFieldSpec.java +++ b/entity-registry/src/main/java/com/linkedin/metadata/models/UrnValidationFieldSpec.java @@ -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; diff --git a/metadata-operation-context/build.gradle b/metadata-operation-context/build.gradle index 23274d537a..46493ce533 100644 --- a/metadata-operation-context/build.gradle +++ b/metadata-operation-context/build.gradle @@ -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 diff --git a/metadata-operation-context/src/main/java/io/datahubproject/metadata/context/ObjectMapperContext.java b/metadata-operation-context/src/main/java/io/datahubproject/metadata/context/ObjectMapperContext.java index a25deee238..ee101c2694 100644 --- a/metadata-operation-context/src/main/java/io/datahubproject/metadata/context/ObjectMapperContext.java +++ b/metadata-operation-context/src/main/java/io/datahubproject/metadata/context/ObjectMapperContext.java @@ -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 = diff --git a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/ObjectMapperFactory.java b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/ObjectMapperFactory.java index ee0aa281e4..cad0d38e2a 100644 --- a/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/ObjectMapperFactory.java +++ b/metadata-service/factories/src/main/java/com/linkedin/gms/factory/common/ObjectMapperFactory.java @@ -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; } } diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectAnnotationDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectAnnotationDto.java new file mode 100644 index 0000000000..e7fdc519f0 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectAnnotationDto.java @@ -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 renderSpec; +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectSpecDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectSpecDto.java new file mode 100644 index 0000000000..a3ad171b58 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/AspectSpecDto.java @@ -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 searchableFieldSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map searchableRefFieldSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map searchScoreFieldSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map relationshipFieldSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map timeseriesFieldSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map timeseriesFieldCollectionSpec; + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map 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 + Map convertFieldSpecMap(Map fieldSpecMap) { + if (fieldSpecMap == null) { + return new HashMap<>(); + } + Map 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 convertFieldSpecMapFromList( + List fieldSpecs) { + if (fieldSpecs == null || fieldSpecs.isEmpty()) { + return null; // Return null instead of empty map + } + Map 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 convertDataMap(com.linkedin.data.DataMap dataMap) { + if (dataMap == null) { + return null; + } + Map result = new HashMap<>(); + dataMap.forEach(result::put); + return result; + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntityAnnotationDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntityAnnotationDto.java new file mode 100644 index 0000000000..04e91c294a --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntityAnnotationDto.java @@ -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(); + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecDto.java new file mode 100644 index 0000000000..32101e1447 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecDto.java @@ -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 aspectSpecs; + private Map aspectSpecMap; + + private List searchableFieldSpecs; + private Map> searchableFieldTypes; + private List searchScoreFieldSpecs; + private List relationshipFieldSpecs; + private List 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> convertSearchableFieldTypes( + Map> 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; + } + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecResponse.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecResponse.java new file mode 100644 index 0000000000..590f7e55c8 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EntitySpecResponse.java @@ -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 aspectSpecs; + + public static EntitySpecResponse fromEntitySpec(EntitySpec entitySpec) { + return EntitySpecResponse.builder() + .name(entitySpec.getName()) + .aspectSpecs( + entitySpec.getAspectSpecs().stream() + .map(AspectSpecDto::fromAspectSpec) + .collect(Collectors.toList())) + .build(); + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventAnnotationDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventAnnotationDto.java new file mode 100644 index 0000000000..66052d5245 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventAnnotationDto.java @@ -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(); + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventSpecDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventSpecDto.java new file mode 100644 index 0000000000..a77141d0d6 --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/EventSpecDto.java @@ -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(); + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/FieldSpecDto.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/FieldSpecDto.java new file mode 100644 index 0000000000..6edcb37b3b --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/FieldSpecDto.java @@ -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 pathComponents; + private String fieldType; + + @JsonSerialize(using = DataSchemaSerializer.class) + private DataSchema pegasusSchema; + + private Map annotations; // All annotation data + private Map properties; // Other properties + + public static class DataSchemaSerializer extends JsonSerializer { + @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 annotations = new HashMap<>(); + Map 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 serializeAnnotation(Object annotation) { + Map 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 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 dataMapCopy = new HashMap<>(); + ((com.linkedin.data.DataMap) value).forEach(dataMapCopy::put); + return dataMapCopy; + } + + // Handle Maps recursively + if (value instanceof Map) { + Map 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; + } +} diff --git a/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/PaginatedResponse.java b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/PaginatedResponse.java new file mode 100644 index 0000000000..6f2e8b16fc --- /dev/null +++ b/metadata-service/openapi-servlet/models/src/main/java/io/datahubproject/openapi/v1/models/registry/PaginatedResponse.java @@ -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 { + private List elements; + private Integer start; + private Integer count; + private Integer total; + + /** Creates a paginated response for DTOs that need custom mapping */ + public static , V, D> PaginatedResponse fromMap( + Map items, + Integer start, + Integer count, + java.util.function.Function, D> mapper) { + + if (items == null || items.isEmpty()) { + return PaginatedResponse.builder().elements(List.of()).start(0).count(0).total(0).build(); + } + + // Sort by key and map to DTOs + List 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 pageItems = sortedItems.subList(actualStart, endIndex); + + return PaginatedResponse.builder() + .elements(pageItems) + .start(actualStart) + .count(pageItems.size()) + .total(sortedItems.size()) + .build(); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/EntityRegistryController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/EntityRegistryController.java new file mode 100644 index 0000000000..9a31b36f10 --- /dev/null +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/EntityRegistryController.java @@ -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> 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 entitySpecs = + systemOperationContext.getEntityRegistry().getEntitySpecs(); + + PaginatedResponse 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 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> 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 aspectSpecs = + systemOperationContext.getEntityRegistry().getEntitySpec(entityName).getAspectSpecMap(); + Map 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> 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 aspectSpecs = + systemOperationContext.getEntityRegistry().getAspectSpecs(); + + PaginatedResponse 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 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> 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 eventSpecs = systemOperationContext.getEntityRegistry().getEventSpecs(); + + // Use the PaginatedResponse helper to handle pagination and sorting + PaginatedResponse 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); + } +} diff --git a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/LineageRegistryController.java b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/LineageRegistryController.java index 2d6818aea5..cefa78daed 100644 --- a/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/LineageRegistryController.java +++ b/metadata-service/openapi-servlet/src/main/java/io/datahubproject/openapi/v1/registry/LineageRegistryController.java @@ -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", diff --git a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/EntityRegistryControllerTest.java b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/EntityRegistryControllerTest.java new file mode 100644 index 0000000000..e05e476ed9 --- /dev/null +++ b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/EntityRegistryControllerTest.java @@ -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 authUtilMock; + private MockedStatic 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))); + } +} diff --git a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/LineageRegistryControllerTest.java b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/LineageRegistryControllerTest.java index 8e7f226073..020927f840 100644 --- a/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/LineageRegistryControllerTest.java +++ b/metadata-service/openapi-servlet/src/test/java/io/datahubproject/openapi/v1/registry/LineageRegistryControllerTest.java @@ -32,7 +32,6 @@ public class LineageRegistryControllerTest { private MockedStatic authContextMock; private static final String TEST_ENTITY_NAME = "dataset"; - private static final String TEST_DOWNSTREAM_ENTITY = "chart"; @BeforeMethod public void setup() { diff --git a/metadata-service/war/src/main/java/com/linkedin/gms/ServletConfig.java b/metadata-service/war/src/main/java/com/linkedin/gms/ServletConfig.java index 869ddc3b52..eb932f288f 100644 --- a/metadata-service/war/src/main/java/com/linkedin/gms/ServletConfig.java +++ b/metadata-service/war/src/main/java/com/linkedin/gms/ServletConfig.java @@ -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);