Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
247 changes: 234 additions & 13 deletions src/main/java/com/google/genai/Common.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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<Duration> {
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<Duration> {
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> T fromJsonString(String jsonString, Class<T> 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> T fromJsonNode(JsonNode jsonNode, Class<T> 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. */
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -333,7 +558,7 @@ public static void moveValueByPath(JsonNode data, Map<String, String> paths) {
String[] destKeys = destPath.split("\\.");

// Determine keys to exclude from wildcard to avoid cyclic references
java.util.Set<String> excludeKeys = new java.util.HashSet<>();
Set<String> excludeKeys = new HashSet<>();
int wildcardIdx = -1;

for (int i = 0; i < sourceKeys.length; i++) {
Expand Down Expand Up @@ -370,11 +595,7 @@ public static void moveValueByPath(JsonNode data, Map<String, String> paths) {
* @param excludeKeys Keys to exclude when processing wildcards
*/
public static void moveValueRecursive(
JsonNode data,
String[] sourceKeys,
String[] destKeys,
int keyIdx,
java.util.Set<String> excludeKeys) {
JsonNode data, String[] sourceKeys, String[] destKeys, int keyIdx, Set<String> excludeKeys) {
if (keyIdx >= sourceKeys.length || data == null) {
return;
}
Expand All @@ -398,7 +619,7 @@ public static void moveValueRecursive(
ObjectNode objectNode = (ObjectNode) data;

// Get all keys to move (excluding specified keys)
java.util.List<String> keysToMove = new java.util.ArrayList<>();
List<String> keysToMove = new ArrayList<>();
Iterator<String> fieldNames = objectNode.fieldNames();
while (fieldNames.hasNext()) {
String fieldName = fieldNames.next();
Expand All @@ -408,7 +629,7 @@ public static void moveValueRecursive(
}

// Collect values to move
java.util.Map<String, JsonNode> valuesToMove = new java.util.HashMap<>();
Map<String, JsonNode> valuesToMove = new HashMap<>();
for (String k : keysToMove) {
valuesToMove.put(k, objectNode.get(k));
}
Expand All @@ -419,7 +640,7 @@ public static void moveValueRecursive(
JsonNode v = entry.getValue();

// Build destination keys with the field name
java.util.List<String> newDestKeysList = new java.util.ArrayList<>();
List<String> newDestKeysList = new ArrayList<>();
for (int i = keyIdx; i < destKeys.length; i++) {
String dk = destKeys[i];
if (dk.equals("*")) {
Expand Down
Loading
Loading