Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add strategies for unknown and missing fields #2358

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
11 changes: 10 additions & 1 deletion gson/src/main/java/com/google/gson/Gson.java
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ public final class Gson {
static final FieldNamingStrategy DEFAULT_FIELD_NAMING_STRATEGY = FieldNamingPolicy.IDENTITY;
static final ToNumberStrategy DEFAULT_OBJECT_TO_NUMBER_STRATEGY = ToNumberPolicy.DOUBLE;
static final ToNumberStrategy DEFAULT_NUMBER_TO_NUMBER_STRATEGY = ToNumberPolicy.LAZILY_PARSED_NUMBER;
static final MissingFieldValueStrategy DEFAULT_MISSING_FIELD_VALUE_STRATEGY = MissingFieldValueStrategy.DO_NOTHING;
static final UnknownFieldStrategy DEFAULT_UNKNOWN_FIELD_STRATEGY = UnknownFieldStrategy.IGNORE;

private static final String JSON_NON_EXECUTABLE_PREFIX = ")]}'\n";

Expand Down Expand Up @@ -195,6 +197,8 @@ public final class Gson {
final List<TypeAdapterFactory> builderHierarchyFactories;
final ToNumberStrategy objectToNumberStrategy;
final ToNumberStrategy numberToNumberStrategy;
final MissingFieldValueStrategy missingFieldValueStrategy;
final UnknownFieldStrategy unknownFieldStrategy;
final List<ReflectionAccessFilter> reflectionFilters;

/**
Expand Down Expand Up @@ -242,6 +246,7 @@ public Gson() {
LongSerializationPolicy.DEFAULT, DEFAULT_DATE_PATTERN, DateFormat.DEFAULT, DateFormat.DEFAULT,
Collections.<TypeAdapterFactory>emptyList(), Collections.<TypeAdapterFactory>emptyList(),
Collections.<TypeAdapterFactory>emptyList(), DEFAULT_OBJECT_TO_NUMBER_STRATEGY, DEFAULT_NUMBER_TO_NUMBER_STRATEGY,
DEFAULT_MISSING_FIELD_VALUE_STRATEGY, DEFAULT_UNKNOWN_FIELD_STRATEGY,
Collections.<ReflectionAccessFilter>emptyList());
}

Expand All @@ -255,6 +260,7 @@ public Gson() {
List<TypeAdapterFactory> builderHierarchyFactories,
List<TypeAdapterFactory> factoriesToBeAdded,
ToNumberStrategy objectToNumberStrategy, ToNumberStrategy numberToNumberStrategy,
MissingFieldValueStrategy missingFieldValueStrategy, UnknownFieldStrategy unknownFieldStrategy,
List<ReflectionAccessFilter> reflectionFilters) {
this.excluder = excluder;
this.fieldNamingStrategy = fieldNamingStrategy;
Expand All @@ -276,6 +282,8 @@ public Gson() {
this.builderHierarchyFactories = builderHierarchyFactories;
this.objectToNumberStrategy = objectToNumberStrategy;
this.numberToNumberStrategy = numberToNumberStrategy;
this.missingFieldValueStrategy = missingFieldValueStrategy;
this.unknownFieldStrategy = unknownFieldStrategy;
this.reflectionFilters = reflectionFilters;

List<TypeAdapterFactory> factories = new ArrayList<>();
Expand Down Expand Up @@ -341,7 +349,8 @@ public Gson() {
factories.add(jsonAdapterFactory);
factories.add(TypeAdapters.ENUM_FACTORY);
factories.add(new ReflectiveTypeAdapterFactory(
constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory, reflectionFilters));
constructorConstructor, fieldNamingStrategy, excluder, jsonAdapterFactory,
missingFieldValueStrategy, unknownFieldStrategy, reflectionFilters));

this.factories = Collections.unmodifiableList(factories);
}
Expand Down
46 changes: 42 additions & 4 deletions gson/src/main/java/com/google/gson/GsonBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@
import static com.google.gson.Gson.DEFAULT_FORMATTING_STYLE;
import static com.google.gson.Gson.DEFAULT_JSON_NON_EXECUTABLE;
import static com.google.gson.Gson.DEFAULT_LENIENT;
import static com.google.gson.Gson.DEFAULT_MISSING_FIELD_VALUE_STRATEGY;
import static com.google.gson.Gson.DEFAULT_NUMBER_TO_NUMBER_STRATEGY;
import static com.google.gson.Gson.DEFAULT_OBJECT_TO_NUMBER_STRATEGY;
import static com.google.gson.Gson.DEFAULT_SERIALIZE_NULLS;
import static com.google.gson.Gson.DEFAULT_SPECIALIZE_FLOAT_VALUES;
import static com.google.gson.Gson.DEFAULT_UNKNOWN_FIELD_STRATEGY;
import static com.google.gson.Gson.DEFAULT_USE_JDK_UNSAFE;

import com.google.gson.annotations.Since;
Expand Down Expand Up @@ -56,7 +58,7 @@
* use {@code new Gson()}. {@code GsonBuilder} is best used by creating it, and then invoking its
* various configuration methods, and finally calling create.</p>
*
* <p>The following is an example shows how to use the {@code GsonBuilder} to construct a Gson
* <p>The following example shows how to use the {@code GsonBuilder} to construct a Gson
* instance:
*
* <pre>
Expand All @@ -73,8 +75,8 @@
*
* <p>NOTES:
* <ul>
* <li> the order of invocation of configuration methods does not matter.</li>
* <li> The default serialization of {@link Date} and its subclasses in Gson does
* <li>the order of invocation of configuration methods does not matter.</li>
* <li>the default serialization of {@link Date} and its subclasses in Gson does
* not contain time-zone information. So, if you are using date/time instances,
* use {@code GsonBuilder} and its {@code setDateFormat} methods.</li>
* </ul>
Expand Down Expand Up @@ -104,6 +106,8 @@ public final class GsonBuilder {
private boolean useJdkUnsafe = DEFAULT_USE_JDK_UNSAFE;
private ToNumberStrategy objectToNumberStrategy = DEFAULT_OBJECT_TO_NUMBER_STRATEGY;
private ToNumberStrategy numberToNumberStrategy = DEFAULT_NUMBER_TO_NUMBER_STRATEGY;
private MissingFieldValueStrategy missingFieldValueStrategy = DEFAULT_MISSING_FIELD_VALUE_STRATEGY;
private UnknownFieldStrategy unknownFieldStrategy = DEFAULT_UNKNOWN_FIELD_STRATEGY;
private final ArrayDeque<ReflectionAccessFilter> reflectionFilters = new ArrayDeque<>();

/**
Expand Down Expand Up @@ -141,6 +145,8 @@ public GsonBuilder() {
this.useJdkUnsafe = gson.useJdkUnsafe;
this.objectToNumberStrategy = gson.objectToNumberStrategy;
this.numberToNumberStrategy = gson.numberToNumberStrategy;
this.missingFieldValueStrategy = gson.missingFieldValueStrategy;
this.unknownFieldStrategy = gson.unknownFieldStrategy;
this.reflectionFilters.addAll(gson.reflectionFilters);
}

Expand Down Expand Up @@ -388,6 +394,37 @@ public GsonBuilder setObjectToNumberStrategy(ToNumberStrategy objectToNumberStra
return this;
}

/**
* Configures Gson to apply a specific missing field value strategy during deserialization.
* The strategy is used during reflection-based deserialization when the JSON data does
* not contain a value for a field. A field with explicit JSON null is not considered missing.
*
* @param missingFieldValueStrategy strategy handling missing field values
* @return a reference to this {@code GsonBuilder} object to fulfill the "Builder" pattern
* @see MissingFieldValueStrategy#DO_NOTHING The default missing field value strategy
* @since $next-version$
*/
public GsonBuilder setMissingFieldValueStrategy(MissingFieldValueStrategy missingFieldValueStrategy) {
this.missingFieldValueStrategy = Objects.requireNonNull(missingFieldValueStrategy);
return this;
}

/**
* Configures Gson to apply a specific unknown field strategy during deserialization.
* The strategy is used during reflection-based deserialization when an unknown field
* is encountered in the JSON data. If a field which is excluded from deserialization
* appears in the JSON data it is considered unknown as well.
Comment on lines +415 to +416
Copy link
Collaborator Author

@Marcono1234 Marcono1234 Mar 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might be use cases where a field excluded from deserialization should not be considered unknown, but should just be silently ignored.

With the current UnknownFieldStrategy API users might be able to determine themselves if a field is excluded, but not in a very reliable way since there are multiple ways in which a field can be excluded (e.g. modifiers, exclusion strategy, @Expose) and Gson does not expose direct ways to test this.

We could possibly add a boolean excluded parameter to UnknownFieldStrategy.handleUnknownField whose value is true if ReflectiveTypeAdapterFactory finds a BoundField but it has !field.deserialized. Though I am not sure if that is really worth it. What are your opinions?

*
* @param unknownFieldStrategy strategy handling unknown fields
* @return a reference to this {@code GsonBuilder} object to fulfill the "Builder" pattern
* @see UnknownFieldStrategy#IGNORE The default unknown field strategy
* @since $next-version$
*/
public GsonBuilder setUnknownFieldStrategy(UnknownFieldStrategy unknownFieldStrategy) {
this.unknownFieldStrategy = Objects.requireNonNull(unknownFieldStrategy);
return this;
}

/**
* Configures Gson to apply a specific number strategy during deserialization of {@link Number}.
*
Expand Down Expand Up @@ -782,7 +819,8 @@ public Gson create() {
serializeSpecialFloatingPointValues, useJdkUnsafe, longSerializationPolicy,
datePattern, dateStyle, timeStyle, new ArrayList<>(this.factories),
new ArrayList<>(this.hierarchyFactories), factories,
objectToNumberStrategy, numberToNumberStrategy, new ArrayList<>(reflectionFilters));
objectToNumberStrategy, numberToNumberStrategy,
missingFieldValueStrategy, unknownFieldStrategy, new ArrayList<>(reflectionFilters));
}

private void addTypeAdaptersForDate(String datePattern, int dateStyle, int timeStyle,
Expand Down
77 changes: 77 additions & 0 deletions gson/src/main/java/com/google/gson/MissingFieldValueStrategy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.google.gson;

import com.google.gson.internal.reflect.ReflectionHelper;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Field;

/**
* A strategy defining how to handle missing field values during reflection-based deserialization.
*
Copy link
Collaborator Author

@Marcono1234 Marcono1234 Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should have a warning that users should be careful when implementing security critical validation with this, and should add extensive tests. Because if they forget to call setMissingFieldValueStrategy or use a default Gson instance somewhere, then their custom strategy will not be used.

* @see GsonBuilder#setMissingFieldValueStrategy(MissingFieldValueStrategy)
* @since $next-version$
*/
public interface MissingFieldValueStrategy {
/**
* This strategy does nothing when a missing field is detected, it preserves the initial field
* value, if any.
*
* <p>This is the default missing field value strategy.
*/
MissingFieldValueStrategy DO_NOTHING = new MissingFieldValueStrategy() {
@Override
public Object handleMissingField(TypeToken<?> declaringType, Object instance, Field field, TypeToken<?> resolvedFieldType) {
// Preserve initial field value
return null;
}

@Override
public String toString() {
return "MissingFieldValueStrategy.DO_NOTHING";
}
};

/**
* This strategy throws an exception when a missing field is detected.
*/
MissingFieldValueStrategy THROW_EXCEPTION = new MissingFieldValueStrategy() {
@Override
public Object handleMissingField(TypeToken<?> declaringType, Object instance, Field field, TypeToken<?> resolvedFieldType) {
// TODO: Proper exception
throw new RuntimeException("Missing value for field '" + ReflectionHelper.fieldToString(field) + "'");
}

@Override
public String toString() {
return "MissingFieldValueStrategy.THROW_EXCEPTION";
}
};

/**
* Called when a missing field value is detected. Implementations can either throw an exception or
* return a default value.
*
* <p>Returning {@code null} will keep the initial field value, if any. For example when returning
* {@code null} for the field {@code String f = "default"}, the field will still have the value
* {@code "default"} afterwards (assuming the constructor of the class was called, see also
* {@link GsonBuilder#disableJdkUnsafe()}). The type of the returned value has to match the
* type of the field, no narrowing or widening numeric conversion is performed.
*
* <p>The {@code instance} represents an instance of the declaring type with the so far already
* deserialized fields. It is intended to be used for looking up existing field values to derive
* the missing field value from them. Manipulating {@code instance} in any way is not recommended.<br>
* For Record classes (Java 16 feature) the {@code instance} is {@code null}.
*
* <p>{@code resolvedFieldType} is the type of the field with type variables being resolved, if
* possible. For example if {@code class MyClass<T>} has a field {@code T myField} and
* {@code MyClass<String>} is deserialized, then {@code resolvedFieldType} will be {@code String}.
*
* @param declaringType type declaring the field
* @param instance instance of the declaring type, {@code null} for Record classes
* @param field field whose value is missing
* @param resolvedFieldType resolved type of the field
* @return the field value, or {@code null}
*/
// TODO: Should this really expose `instance`? Only use case would be to derive value from other fields
// but besides that user should not directly manipulate `instance` but return new value instead
Object handleMissingField(TypeToken<?> declaringType, Object instance, Field field, TypeToken<?> resolvedFieldType);

Check notice

Code scanning / CodeQL

Useless parameter

The parameter 'instance' is never used.

Check notice

Code scanning / CodeQL

Useless parameter

The parameter 'resolvedFieldType' is never used.

Check notice

Code scanning / CodeQL

Useless parameter

The parameter 'declaringType' is never used.
Comment on lines +74 to +76
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not completely sure whether it is really necessary to expose instance.

}
16 changes: 16 additions & 0 deletions gson/src/main/java/com/google/gson/ReflectionAccessFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ enum FilterResult {
? FilterResult.BLOCK_INACCESSIBLE
: FilterResult.INDECISIVE;
}

@Override public String toString() {
return "ReflectionAccessFilter.BLOCK_INACCESSIBLE_JAVA";
}
};

/**
Expand All @@ -149,6 +153,10 @@ enum FilterResult {
? FilterResult.BLOCK_ALL
: FilterResult.INDECISIVE;
}

@Override public String toString() {
return "ReflectionAccessFilter.BLOCK_ALL_JAVA";
}
};

/**
Expand All @@ -173,6 +181,10 @@ enum FilterResult {
? FilterResult.BLOCK_ALL
: FilterResult.INDECISIVE;
}

@Override public String toString() {
return "ReflectionAccessFilter.BLOCK_ALL_ANDROID";
}
};

/**
Expand All @@ -198,6 +210,10 @@ enum FilterResult {
? FilterResult.BLOCK_ALL
: FilterResult.INDECISIVE;
}

@Override public String toString() {
return "ReflectionAccessFilter.BLOCK_ALL_PLATFORM";
}
};

/**
Expand Down
79 changes: 79 additions & 0 deletions gson/src/main/java/com/google/gson/UnknownFieldStrategy.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.google.gson;

import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import java.io.IOException;

/**
* A strategy defining how to handle unknown fields during reflection-based deserialization.
*
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should have a warning that users should be careful when implementing security critical validation with this, and should add extensive tests. Because if they forget to call setUnknownFieldStrategy or use a default Gson instance somewhere, then their custom strategy will not be used.

* @see GsonBuilder#setUnknownFieldStrategy(UnknownFieldStrategy)
* @since $next-version$
*/
public interface UnknownFieldStrategy {
/**
* This strategy ignores the unknown field.
*
* <p>This is the default unknown field strategy.
*/
UnknownFieldStrategy IGNORE = new UnknownFieldStrategy() {
@Override
public void handleUnknownField(TypeToken<?> declaringType, Object instance, String fieldName,
JsonReader jsonReader, Gson gson) throws IOException {
jsonReader.skipValue();
}

@Override
public String toString() {
return "UnknownFieldStrategy.IGNORE";
}
};

/**
* This strategy throws an exception when an unknown field is encountered.
*
* <p><b>Note:</b> Be careful when using this strategy; while it might sound tempting
* to strictly validate that the JSON data matches the expected format, this strategy
* makes it difficult to add new fields to the JSON structure in a backward compatible way.
* Usually it suffices to use only {@link MissingFieldValueStrategy#THROW_EXCEPTION} for
* validation and to ignore unknown fields.
*/
UnknownFieldStrategy THROW_EXCEPTION = new UnknownFieldStrategy() {
@Override
public void handleUnknownField(TypeToken<?> declaringType, Object instance, String fieldName,
JsonReader jsonReader, Gson gson) throws IOException {
// TODO: Proper exception
throw new RuntimeException("Unknown field '" + fieldName + "' for " + declaringType.getRawType() + " at path " + jsonReader.getPath());
}

@Override
public String toString() {
return "UnknownFieldStrategy.THROW_EXCEPTION";
}
};

/**
* Called when an unknown field is encountered. Implementations can throw an exception,
* store the field value in {@code instance} or ignore the unknown field.
*
* <p>The {@code jsonReader} is positioned to read the value of the unknown field. If an
* implementation of this method does not throw an exception it must consume the value, either
* by reading it with methods like {@link JsonReader#nextString()} (possibly after peeking
* at the value type first), or by skipping it with {@link JsonReader#skipValue()}.<br>
* The {@code gson} object can be used to read from the {@code jsonReader}. It is the same
* instance which was originally used to perform the deserialization.
*
* <p>The {@code instance} represents an instance of the declaring type with the so far already
* deserialized fields. It can be used to store the value of the unknown field, for example
* if it declares a {@code transient Map<String, Object>} field for all unknown values.<br>
* For Record classes (Java 16 feature) the {@code instance} is {@code null}.
*
* @param declaringType type declaring the field
* @param instance instance of the declaring type, {@code null} for Record classes
* @param fieldName name of the unknown field
* @param jsonReader reader to be used to read or skip the field value
* @param gson {@code Gson} instance which can be used to read the field value from {@code jsonReader}
* @throws IOException if reading or skipping the field value fails
*/
void handleUnknownField(TypeToken<?> declaringType, Object instance, String fieldName, JsonReader jsonReader, Gson gson) throws IOException;

Check notice

Code scanning / CodeQL

Useless parameter

The parameter 'instance' is never used.

Check notice

Code scanning / CodeQL

Useless parameter

The parameter 'gson' is never used.
}
Loading