diff --git a/src/main/java/com/google/genai/Common.java b/src/main/java/com/google/genai/Common.java index df84ea9ae02..3747034dd9f 100644 --- a/src/main/java/com/google/genai/Common.java +++ b/src/main/java/com/google/genai/Common.java @@ -16,27 +16,212 @@ package com.google.genai; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.api.core.InternalApi; +import com.google.common.base.Strings; import com.google.genai.errors.GenAiIOException; import com.google.genai.types.HttpOptions; +import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.StringJoiner; +import java.util.logging.Logger; import org.jspecify.annotations.Nullable; -/** Common utility methods for the GenAI SDK. */ +/** + * Common utility methods for the GenAI SDK. + * + *

All utility methods in this class are for internal use only and are subject to change without + * notice. + */ @InternalApi public final class Common { + @InternalApi public static final ObjectMapper objectMapper = new ObjectMapper(); + private static final Logger logger = Logger.getLogger(Common.class.getName()); + + /** + * System property to override the default max JSON string length (20MB) in read constraints. + * E.g., if you want to change the limit to 100MB, you can set it via + * `-Dgenai.json.maxReadLength=100000000`. + */ + public static final String MAX_READ_LENGTH_PROPERTY = "genai.json.maxReadLength"; + + /** Custom Jackson serializer for {@link Duration} to output "Xs" format. */ + @SuppressWarnings("JavaDurationGetSecondsToToSeconds") + public static class CustomDurationSerializer extends JsonSerializer { + public CustomDurationSerializer() {} + + @Override + public void serialize( + Duration duration, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + if (duration == null) { + jsonGenerator.writeNull(); + } else { + jsonGenerator.writeString(duration.getSeconds() + "s"); + } + } + } + + /** Custom Jackson deserializer for {@link Duration} to parse "Xs" format. */ + public static class CustomDurationDeserializer extends JsonDeserializer { + public CustomDurationDeserializer() {} + + @Override + public @Nullable Duration deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + String value = p.getValueAsString(); + + if (Strings.isNullOrEmpty(value)) { + return null; + } + if (value.endsWith("s")) { + String secondsPart = value.substring(0, value.length() - 1); + try { + long seconds = Long.parseLong(secondsPart); + return Duration.ofSeconds(seconds); + } catch (NumberFormatException e) { + JsonMappingException exception = + ctxt.weirdStringException( + value, + Duration.class, + "Cannot parse duration from string: " + value + ". Expected format 'Xs'."); + exception.initCause(e); + throw exception; + } + } else { + throw ctxt.weirdStringException( + value, Duration.class, "Expected duration in format 'Xs', but got: " + value); + } + } + } + + /** Configures the stream read constraints for the JSON parser. */ + private static void configureStreamReadConstraints(int maxReadLength) { + if (maxReadLength <= 0) { + throw new IllegalArgumentException("Invalid JSON max read length: " + maxReadLength); + } + logger.info("Setting Jackson max read length to " + maxReadLength); + + StreamReadConstraints streamReadConstraints = + StreamReadConstraints.builder().maxStringLength(maxReadLength).build(); + + objectMapper.getFactory().setStreamReadConstraints(streamReadConstraints); + } + + static { + // Configure default stream read constraints. + int maxReadLength = 20000000; // 20MB + String maxReadLengthString = System.getProperty(MAX_READ_LENGTH_PROPERTY); + if (maxReadLengthString != null) { + try { + maxReadLength = Integer.parseInt(maxReadLengthString); + } catch (NumberFormatException e) { + logger.warning( + "Invalid JSON max read length in property " + + MAX_READ_LENGTH_PROPERTY + + ": " + + maxReadLengthString + + ". Using default value: " + + maxReadLength); + } + } + configureStreamReadConstraints(maxReadLength); + + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT); + objectMapper.registerModule(new Jdk8Module()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + SimpleModule durationModule = new SimpleModule(); + durationModule.addSerializer(Duration.class, new CustomDurationSerializer()); + durationModule.addDeserializer(Duration.class, new CustomDurationDeserializer()); + + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.registerModule(durationModule); + } + + /** Sets the maximum allowed string length for Jackson JSON parsing. */ + public static void setMaxReadLength(int maxReadLength) { + configureStreamReadConstraints(maxReadLength); + } + + /** Returns the shared {@link ObjectMapper} instance used by the SDK. */ + public static ObjectMapper objectMapper() { + return objectMapper; + } + + /** Serializes the given object into a JSON string. */ + public static String toJsonString(Object object) { + try { + return objectMapper.writeValueAsString(object); + } catch (JsonProcessingException e) { + throw new GenAiIOException("Failed to serialize the object to JSON.", e); + } + } + + /** Serializes the given object into a Jackson {@link JsonNode}. */ + public static JsonNode toJsonNode(Object object) { + return objectMapper.valueToTree(object); + } + + /** Deserializes the given JSON string into an instance of {@code clazz}. */ + public static T fromJsonString(String jsonString, Class clazz) { + try { + return objectMapper.readValue(jsonString, clazz); + } catch (JsonProcessingException e) { + throw new GenAiIOException("Failed to deserialize the JSON string.", e); + } + } + + /** Deserializes the given Jackson {@link JsonNode} into an instance of {@code clazz}. */ + public static T fromJsonNode(JsonNode jsonNode, Class clazz) { + try { + return objectMapper.treeToValue(jsonNode, clazz); + } catch (JsonProcessingException e) { + throw new GenAiIOException("Failed to deserialize the JSON node.", e); + } + } + + /** Parses the given JSON string into a Jackson {@link JsonNode}. */ + public static JsonNode stringToJsonNode(String string) { + try { + return objectMapper.readTree(string); + } catch (JsonProcessingException e) { + throw new GenAiIOException("Failed to parse the JSON string.", e); + } + } + private Common() {} /** A class that holds the path, body, and http options of an API request. */ @@ -136,8 +321,40 @@ public static void setValueByPath(ObjectNode jsonObject, String[] path, Object v ObjectNode sourceNode = (ObjectNode) value; currentObject.setAll(sourceNode); } else { - JsonNode valueNode = JsonSerializable.toJsonNode(value); - Transformers.updateJsonNode(currentObject, keyToSet, valueNode); + JsonNode valueNode = toJsonNode(value); + updateJsonNode(currentObject, keyToSet, valueNode); + } + } + + /** Updates an ObjectNode with a key and value, merging objects or avoiding empty overwrites. */ + public static void updateJsonNode(ObjectNode currentObject, String keyToSet, JsonNode valueNode) { + JsonNode existingData = currentObject.get(keyToSet); + + if (existingData != null) { + // Don't overwrite existing non-empty value with new empty value. + if (valueNode == null || valueNode.isNull() || valueNode.isEmpty()) { + return; + } + + // Don't fail when overwriting value with same value + if (valueNode.equals(existingData)) { + return; + } + + // Instead of overwriting dictionary with another dictionary, merge them. + if (existingData.isObject() && valueNode.isObject()) { + ((ObjectNode) existingData).setAll((ObjectNode) valueNode); + } else { + throw new IllegalArgumentException( + "Cannot set value for an existing key. Key: " + + keyToSet + + "; Existing value: " + + existingData + + "; New value: " + + valueNode); + } + } else { + currentObject.set(keyToSet, valueNode); } } @@ -221,6 +438,10 @@ public static void setValueByPath(ObjectNode jsonObject, String[] path, Object v return currentObject; } + /** + * Formats a template string by replacing {@code {key}} placeholders with values from {@code + * data}. + */ public static String formatMap(String template, JsonNode data) { if (data == null) { return template; @@ -238,6 +459,10 @@ public static String formatMap(String template, JsonNode data) { return template; } + /** + * Checks whether the given object represents a zero or default empty value (e.g., null, 0, false, + * '\0'). + */ public static boolean isZero(Object obj) { if (obj == null) { return true; @@ -296,7 +521,7 @@ public static String urlEncode(ObjectNode paramsNode) { /** Converts a snake_case string to camelCase. */ public static String snakeToCamel(String str) { - if (str == null || str.isEmpty()) { + if (Strings.isNullOrEmpty(str)) { return str; } @@ -333,7 +558,7 @@ public static void moveValueByPath(JsonNode data, Map paths) { String[] destKeys = destPath.split("\\."); // Determine keys to exclude from wildcard to avoid cyclic references - java.util.Set excludeKeys = new java.util.HashSet<>(); + Set excludeKeys = new HashSet<>(); int wildcardIdx = -1; for (int i = 0; i < sourceKeys.length; i++) { @@ -370,11 +595,7 @@ public static void moveValueByPath(JsonNode data, Map paths) { * @param excludeKeys Keys to exclude when processing wildcards */ public static void moveValueRecursive( - JsonNode data, - String[] sourceKeys, - String[] destKeys, - int keyIdx, - java.util.Set excludeKeys) { + JsonNode data, String[] sourceKeys, String[] destKeys, int keyIdx, Set excludeKeys) { if (keyIdx >= sourceKeys.length || data == null) { return; } @@ -398,7 +619,7 @@ public static void moveValueRecursive( ObjectNode objectNode = (ObjectNode) data; // Get all keys to move (excluding specified keys) - java.util.List keysToMove = new java.util.ArrayList<>(); + List keysToMove = new ArrayList<>(); Iterator fieldNames = objectNode.fieldNames(); while (fieldNames.hasNext()) { String fieldName = fieldNames.next(); @@ -408,7 +629,7 @@ public static void moveValueRecursive( } // Collect values to move - java.util.Map valuesToMove = new java.util.HashMap<>(); + Map valuesToMove = new HashMap<>(); for (String k : keysToMove) { valuesToMove.put(k, objectNode.get(k)); } @@ -419,7 +640,7 @@ public static void moveValueRecursive( JsonNode v = entry.getValue(); // Build destination keys with the field name - java.util.List newDestKeysList = new java.util.ArrayList<>(); + List newDestKeysList = new ArrayList<>(); for (int i = keyIdx; i < destKeys.length; i++) { String dk = destKeys[i]; if (dk.equals("*")) { diff --git a/src/main/java/com/google/genai/JsonSerializable.java b/src/main/java/com/google/genai/JsonSerializable.java index 52d1db05f4e..55382db7759 100644 --- a/src/main/java/com/google/genai/JsonSerializable.java +++ b/src/main/java/com/google/genai/JsonSerializable.java @@ -16,173 +16,63 @@ package com.google.genai; -import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.core.StreamReadConstraints; -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.JsonSerializer; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.SerializationFeature; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.google.api.core.InternalApi; -import com.google.genai.errors.GenAiIOException; -import java.util.logging.Logger; +import java.time.Duration; /** A class that can be serialized to JSON and deserialized from JSON. */ public abstract class JsonSerializable { - @InternalApi protected static final ObjectMapper objectMapper = new ObjectMapper(); - private static final Logger logger = Logger.getLogger(JsonSerializable.class.getName()); + @InternalApi protected static final ObjectMapper objectMapper = Common.objectMapper; /** * System property to override the default max JSON string length (20MB) in read constraints. * E.g., if you want to change the limit to 100MB, you can set it via * `-Dgenai.json.maxReadLength=100000000`. */ - public static final String MAX_READ_LENGTH_PROPERTY = "genai.json.maxReadLength"; + public static final String MAX_READ_LENGTH_PROPERTY = Common.MAX_READ_LENGTH_PROPERTY; - /** Custom Jackson serializer for {@link java.time.Duration} to output "Xs" format. */ - static class CustomDurationSerializer extends JsonSerializer { - @Override - public void serialize( - java.time.Duration duration, - JsonGenerator jsonGenerator, - SerializerProvider serializerProvider) - throws java.io.IOException { - if (duration == null) { - jsonGenerator.writeNull(); - } else { - jsonGenerator.writeString(duration.getSeconds() + "s"); - } - } + /** Custom Jackson serializer for {@link Duration} to output "Xs" format. */ + public static class CustomDurationSerializer extends Common.CustomDurationSerializer { + public CustomDurationSerializer() {} } - /** Custom Jackson deserializer for {@link java.time.Duration} to parse "Xs" format. */ - static class CustomDurationDeserializer extends JsonDeserializer { - @Override - public java.time.Duration deserialize(JsonParser p, DeserializationContext ctxt) - throws java.io.IOException, JsonProcessingException { - String value = p.getValueAsString(); - - if (value == null || value.isEmpty()) { - return null; - } - if (value.endsWith("s")) { - String secondsPart = value.substring(0, value.length() - 1); - try { - long seconds = Long.parseLong(secondsPart); - return java.time.Duration.ofSeconds(seconds); - } catch (NumberFormatException e) { - throw ctxt.weirdStringException( - value, - java.time.Duration.class, - "Cannot parse duration from string: " + value + ". Expected format 'Xs'."); - } - } else { - // If it doesn't end with 's', delegate to the default deserializer. - throw ctxt.weirdStringException( - value, java.time.Duration.class, "Expected duration in format 'Xs', but got: " + value); - } - } - } - - /** Configures the stream read constraints for the JSON parser. */ - private static void configureStreamReadConstraints(int maxReadLength) { - if (maxReadLength <= 0) { - throw new IllegalArgumentException("Invalid JSON max read length: " + maxReadLength); - } - logger.info("Overriding default JSON max string length. New value = " + maxReadLength); - StreamReadConstraints constraints = - StreamReadConstraints.builder().maxStringLength(maxReadLength).build(); - objectMapper.getFactory().setStreamReadConstraints(constraints); - } - - static { - objectMapper.setSerializationInclusion(JsonInclude.Include.NON_ABSENT); - objectMapper.registerModule(new Jdk8Module()); - // Disable writing dates as timestamps to use ISO-8601 string format for Instant - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - - // Create a module for custom serializers/deserializers - SimpleModule customModule = new SimpleModule(); - customModule.addSerializer(java.time.Duration.class, new CustomDurationSerializer()); - customModule.addDeserializer(java.time.Duration.class, new CustomDurationDeserializer()); - - // Register JavaTimeModule for other java.time types *before* the custom module - // This ensures our custom Duration handling takes precedence over the default one - // provided by JavaTimeModule. - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.registerModule(customModule); - - try { - String propertyValue = System.getProperty(MAX_READ_LENGTH_PROPERTY); - if (propertyValue != null && !propertyValue.isEmpty()) { - int maxStringLength = Integer.parseInt(propertyValue); - configureStreamReadConstraints(maxStringLength); - } - } catch (NumberFormatException e) { - logger.warning( - "Failed to parse system property [" - + MAX_READ_LENGTH_PROPERTY - + "]. Using default 20MB limit."); - } + /** Custom Jackson deserializer for {@link Duration} to parse "Xs" format. */ + public static class CustomDurationDeserializer extends Common.CustomDurationDeserializer { + public CustomDurationDeserializer() {} } /** Serializes the instance to a Json string. */ public String toJson() { - return toJsonString(this); + return Common.toJsonString(this); } /** Serializes an object to a Json string. */ public static String toJsonString(Object object) { - try { - return objectMapper.writeValueAsString(object); - } catch (JsonProcessingException e) { - throw new GenAiIOException("Failed to serialize the object to JSON.", e); - } + return Common.toJsonString(object); } /** Serializes an object to a JsonNode. */ public static JsonNode toJsonNode(Object object) { - return objectMapper.valueToTree(object); + return Common.toJsonNode(object); } /** Deserializes a Json string to an object of the given type. This is for internal use only. */ @InternalApi public static T fromJsonString(String jsonString, Class clazz) { - try { - return objectMapper.readValue(jsonString, clazz); - } catch (JsonProcessingException e) { - throw new GenAiIOException("Failed to deserialize the JSON string.", e); - } + return Common.fromJsonString(jsonString, clazz); } /** Deserializes a JsonNode to an object of the given type. */ @InternalApi public static T fromJsonNode(JsonNode jsonNode, Class clazz) { - try { - return objectMapper.treeToValue(jsonNode, clazz); - } catch (JsonProcessingException e) { - throw new GenAiIOException("Failed to deserialize the JSON node.", e); - } + return Common.fromJsonNode(jsonNode, clazz); } /** Converts a Json string to a JsonNode. */ public static JsonNode stringToJsonNode(String string) { - try { - return objectMapper.readTree(string); - } catch (JsonProcessingException e) { - throw new GenAiIOException("Failed to parse the JSON string.", e); - } + return Common.stringToJsonNode(string); } /** @@ -194,10 +84,11 @@ public static JsonNode stringToJsonNode(String string) { * @param maxReadLength the new maximum string length in bytes (e.g., 100_000_000 for 100MB). */ public static void setMaxReadLength(int maxReadLength) { - configureStreamReadConstraints(maxReadLength); + Common.setMaxReadLength(maxReadLength); } + /** Returns the shared {@link ObjectMapper} instance used by the SDK. */ public static ObjectMapper objectMapper() { - return objectMapper; + return Common.objectMapper(); } }