diff --git a/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonUtilsTest.java b/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonUtilsTest.java index 68c854d3c3be..95f7462655d8 100644 --- a/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonUtilsTest.java +++ b/openmetadata-service/src/test/java/org/openmetadata/service/util/JsonUtilsTest.java @@ -39,6 +39,7 @@ import org.openmetadata.schema.services.connections.dashboard.TableauConnection; import org.openmetadata.schema.services.connections.database.MysqlConnection; import org.openmetadata.schema.services.connections.database.common.basicAuth; +import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.utils.JsonUtils; /** This test provides examples of how to use applyPatch */ @@ -272,4 +273,45 @@ void testApplyPatchReplaceOnNullDisplayName_entityWithNonNullAnnotation() { assertEquals("New Display Name", result.getDisplayName()); } + + /** + * Python clients drop fractional seconds when a datetime's microsecond is 0, + * sending "…ssZ" instead of "…ss.SSSSSSZ". The strict global SimpleDateFormat + * rejects that form. Verify the lenient mixin on TagLabel.appliedAt accepts both. + */ + @Test + void testTagLabelAppliedAtAcceptsBareSecondPrecision() { + String withoutFractional = "{\"tagFQN\":\"x.y\",\"appliedAt\":\"2026-04-24T10:27:06Z\"}"; + String withMicros = "{\"tagFQN\":\"x.y\",\"appliedAt\":\"2026-04-24T10:27:06.918000Z\"}"; + + TagLabel bare = JsonUtils.readValue(withoutFractional, TagLabel.class); + TagLabel withFrac = JsonUtils.readValue(withMicros, TagLabel.class); + + assertEquals(0L, bare.getAppliedAt().getTime() % 1000, "bare-second form parses to ms=0"); + assertEquals(918L, withFrac.getAppliedAt().getTime() % 1000, "fractional form preserves ms"); + assertEquals( + 918L, + withFrac.getAppliedAt().getTime() - bare.getAppliedAt().getTime(), + "both forms parse the same second"); + } + + /** + * Malformed ISO strings should surface through the public API as JsonParsingException, with the + * underlying Jackson cause carrying the field path and the lenient deserializer's message. + */ + @Test + void testTagLabelAppliedAtMalformedRaisesMappingException() { + String malformed = "{\"tagFQN\":\"x.y\",\"appliedAt\":\"not-a-date\"}"; + org.openmetadata.schema.exception.JsonParsingException ex = + assertThrows( + org.openmetadata.schema.exception.JsonParsingException.class, + () -> JsonUtils.readValue(malformed, TagLabel.class)); + Throwable cause = ex.getCause(); + assertTrue( + cause instanceof com.fasterxml.jackson.databind.JsonMappingException, + "cause should be JsonMappingException, was: " + cause); + assertTrue( + cause.getMessage().contains("appliedAt") || cause.getMessage().contains("ISO-8601"), + "error should mention the field or expected format: " + cause.getMessage()); + } } diff --git a/openmetadata-spec/src/main/java/org/openmetadata/schema/utils/JsonUtils.java b/openmetadata-spec/src/main/java/org/openmetadata/schema/utils/JsonUtils.java index 30c0069124ff..fa242584cf00 100644 --- a/openmetadata-spec/src/main/java/org/openmetadata/schema/utils/JsonUtils.java +++ b/openmetadata-spec/src/main/java/org/openmetadata/schema/utils/JsonUtils.java @@ -14,14 +14,18 @@ package org.openmetadata.schema.utils; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -50,10 +54,13 @@ import java.nio.file.Paths; import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Date; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; @@ -80,6 +87,7 @@ import org.openmetadata.schema.entity.Type; import org.openmetadata.schema.entity.type.Category; import org.openmetadata.schema.exception.JsonParsingException; +import org.openmetadata.schema.type.TagLabel; @Slf4j public final class JsonUtils { @@ -123,6 +131,11 @@ public final class JsonUtils { // Java 21 optimized introspection/accessors for faster convertValue/read/write paths. OBJECT_MAPPER.registerModule(new BlackbirdModule()); + // Accept TagLabel.appliedAt with or without fractional seconds. Python clients + // serialize datetimes with microsecond=0 as "…ssZ" (no fractional), which the + // strict global SimpleDateFormat("…SSSSSS'Z'") rejects. + OBJECT_MAPPER.addMixIn(TagLabel.class, TagLabelDateMixin.class); + // Lenient ObjectMapper to ignore unknown properties OBJECT_MAPPER_LENIENT = OBJECT_MAPPER.copy(); OBJECT_MAPPER_LENIENT.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -887,4 +900,33 @@ private static Collection getResourcesFromJarFile(File file, Pattern pat } return retval; } + + /** + * Lenient deserializer for ISO-8601 instants. Accepts strings with or without + * fractional seconds, e.g. "2026-04-24T10:27:06Z" and "2026-04-24T10:27:06.918000Z". + * Java's {@link SimpleDateFormat} configured globally with pattern "…SSSSSS'Z'" + * rejects the no-fractional form, which Python clients can emit when a datetime's + * microsecond is 0. + */ + public static final class LenientIsoDateDeserializer extends JsonDeserializer { + @Override + public Date deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getValueAsString(); + if (value == null || value.isEmpty()) { + return null; + } + try { + return Date.from(Instant.parse(value)); + } catch (DateTimeParseException e) { + return (Date) + ctxt.handleWeirdStringValue( + Date.class, value, "Expected ISO-8601 date-time: %s", e.getMessage()); + } + } + } + + abstract static class TagLabelDateMixin { + @JsonDeserialize(using = LenientIsoDateDeserializer.class) + Date appliedAt; + } }