Skip to content

Support extra parameters in typed params #773

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

Merged
merged 6 commits into from
May 7, 2019
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
83 changes: 18 additions & 65 deletions src/main/java/com/stripe/net/ApiRequestParams.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
package com.stripe.net;

import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonObject;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;

import java.io.IOException;
import java.util.Map;

/**
Expand All @@ -23,70 +12,34 @@ public abstract class ApiRequestParams {
/**
* Interface implemented by all enum parameter to get the actual string value that Stripe API
* expects. Internally, it used in custom serialization
* {@link ApiRequestParams.HasEmptyEnumTypeAdapterFactory} converting empty string enum to null.
* {@link ApiRequestParamsConverter} converting empty string enum to
* null.
*/
public interface EnumParam {
String getValue();
}

private static final Gson GSON = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapterFactory(new HasEmptyEnumTypeAdapterFactory())
.create();

private static final UntypedMapDeserializer UNTYPED_MAP_DESERIALIZER =
new UntypedMapDeserializer();

private static class HasEmptyEnumTypeAdapterFactory implements TypeAdapterFactory {
@SuppressWarnings("unchecked")
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (!EnumParam.class.isAssignableFrom(type.getRawType())) {
return null;
}

TypeAdapter<EnumParam> paramEnum = new TypeAdapter<EnumParam>() {
@Override
public void write(JsonWriter out, EnumParam value) throws IOException {
if (value.getValue().equals("")) {
// need to restore serialize null setting
// not to affect other fields
boolean previousSetting = out.getSerializeNulls();
out.setSerializeNulls(true);
out.nullValue();
out.setSerializeNulls(previousSetting);
} else {
out.value(value.getValue());
}
}
/**
* Param key for an `extraParams` map. Any param/sub-param specifying a field
* intended to support extra params from users should have the annotation
* {@code @SerializedName(ApiRequestParams.EXTRA_PARAMS_KEY)}. Logic to handle this is in
* {@link ApiRequestParamsConverter}.
*/
public static final String EXTRA_PARAMS_KEY = "_stripe_java_extra_param_key";

@Override
public EnumParam read(JsonReader in) {
throw new UnsupportedOperationException(
"No deserialization is expected from this private type adapter for enum param.");
}
};
return (TypeAdapter<T>) paramEnum.nullSafe();
}
}
/**
* Converter mapping typed API request parameters into an untyped map.
*/
private static final ApiRequestParamsConverter PARAMS_CONVERTER =
new ApiRequestParamsConverter();

/**
* Convenient method to convert this typed request params into an untyped map. This map is
* composed of {@code Map<String, Object>}, {@code List<Object>}, and basic Java data types.
* This allows you to test building the request params and verify compatibility with your
* prior integrations using the untyped params map
* {@link ApiResource#request(ApiResource.RequestMethod, String, Map, Class, RequestOptions)}.
*
* <p>The peculiarity of this conversion is that `EMPTY` {@link EnumParam} with raw
* value of empty string will be converted to null. This is compatible with the existing
* contract enforcing no empty string in the untyped map params.
*
* <p>Because of the translation from `EMPTY` enum to null, deserializing this map back to a
* request instance is lossy. The null value will not be converted back to the `EMPTY` enum.
* Convert `this` api request params to an untyped map. The conversion is specific to api
* request params object. Please see documentation in
* {@link ApiRequestParamsConverter#convert(ApiRequestParams)}.
*/
public Map<String, Object> toMap() {
JsonObject json = GSON.toJsonTree(this).getAsJsonObject();
return UNTYPED_MAP_DESERIALIZER.deserialize(json);
return PARAMS_CONVERTER.convert(this);
}
}

148 changes: 148 additions & 0 deletions src/main/java/com/stripe/net/ApiRequestParamsConverter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package com.stripe.net;

import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;

import com.stripe.Stripe;
import com.stripe.param.common.EmptyParam;

import java.io.IOException;
import java.util.Map;

/**
* Converter to map an api request object to an untyped map.
* It is not called a *Serializer because the outcome is not a JSON data.
* It is not called *UntypedMapDeserializer because it is not converting from JSON.
*/
class ApiRequestParamsConverter {
/**
* Strategy to flatten extra params in the API request parameters.
*/
private static class ExtraParamsFlatteningStrategy implements UntypedMapDeserializer.Strategy {
@Override
public void deserializeAndTransform(Map<String, Object> outerMap,
Map.Entry<String, JsonElement> jsonEntry,
UntypedMapDeserializer untypedMapDeserializer) {
String key = jsonEntry.getKey();
JsonElement jsonValue = jsonEntry.getValue();
if (ApiRequestParams.EXTRA_PARAMS_KEY.equals(key)) {
if (!jsonValue.isJsonObject()) {
throw new IllegalStateException(String.format(
"Unexpected schema for extra params. JSON object is expected at key `%s`, but found"
+ " `%s`. This is likely a problem with this current library version `%s`. "
+ "Please contact [email protected] for assistance.",
ApiRequestParams.EXTRA_PARAMS_KEY, jsonValue, Stripe.VERSION));
}
// JSON value now corresponds to the extra params map, and is also deserialized as a map.
// Instead of putting this result map under the original key, flatten the map
// by adding all its key/value pairs to the outer map instead.
Map<String, Object> extraParamsMap =
untypedMapDeserializer.deserialize(jsonValue.getAsJsonObject());
for (Map.Entry<String, Object> entry : extraParamsMap.entrySet()) {
validateDuplicateKey(outerMap, entry.getKey(), entry.getValue());
outerMap.put(entry.getKey(), entry.getValue());
}
} else {
Object value = untypedMapDeserializer.deserializeJsonElement(jsonValue);
validateDuplicateKey(outerMap, key, value);

// Normal deserialization where output map has the same structure as the given JSON content.
// The deserialized content is an untyped `Object` and added to the outer map at the
// original key.
outerMap.put(key, value);
}
}
}

private static void validateDuplicateKey(Map<String, Object> outerMap,
String paramKey, Object paramValue) {
if (outerMap.containsKey(paramKey)) {
throw new IllegalArgumentException(String.format(
"Found multiple param values for the same param key. This can happen because you passed "
+ "additional parameters via `putExtraParam` that conflict with the existing params. "
+ "Found param key `%s` with values `%s` and `%s`. "
+ "If you wish to pass additional params for nested parameters, you "
+ "should add extra params at the nested params themselves, not from the "
+ "top-level param.",
paramKey, outerMap.get(paramKey), paramValue));
}
}

/**
* Type adapter to convert an empty enum to null value to comply with the lower-lever encoding
* logic for the API request parameters.
*/
private static class HasEmptyEnumTypeAdapterFactory implements TypeAdapterFactory {
@SuppressWarnings("unchecked")
@Override
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
if (!ApiRequestParams.EnumParam.class.isAssignableFrom(type.getRawType())) {
return null;
}

TypeAdapter<ApiRequestParams.EnumParam> paramEnum =
new TypeAdapter<ApiRequestParams.EnumParam>() {
@Override
public void write(JsonWriter out, ApiRequestParams.EnumParam value) throws IOException {
if (value.getValue().equals("")) {
// need to restore serialize null setting
// not to affect other fields
boolean previousSetting = out.getSerializeNulls();
out.setSerializeNulls(true);
out.nullValue();
out.setSerializeNulls(previousSetting);
} else {
out.value(value.getValue());
}
}

@Override
public ApiRequestParams.EnumParam read(JsonReader in) {
throw new UnsupportedOperationException(
"No deserialization is expected from this private type adapter for enum param.");
}
};
return (TypeAdapter<T>) paramEnum.nullSafe();
}
}

private static final Gson GSON = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapterFactory(new ApiRequestParamsConverter.HasEmptyEnumTypeAdapterFactory())
.create();

private static final UntypedMapDeserializer FLATTENING_EXTRA_PARAMS_DESERIALIZER =
new UntypedMapDeserializer(new ExtraParamsFlatteningStrategy());

/**
* Convert the given request params into an untyped map. This map is
* composed of {@code Map<String, Object>}, {@code List<Object>}, and basic Java data types.
* This allows you to test building the request params and verify compatibility with your
* prior integrations using the untyped params map
* {@link ApiResource#request(ApiResource.RequestMethod, String, Map, Class, RequestOptions)}.
*
* <p>There are two peculiarities in this conversion:
*
* <p>1) {@link EmptyParam#EMPTY}, containing a raw empty string value, is converted to null.
* This is because the form-encoding layer prohibits passing empty string as a param map value.
* It, however, allows a null value in the map (present key but null value).
* Because of the translation from `EMPTY` enum to null, deserializing this map back to a
* request instance is lossy. The null value will not be converted back to the `EMPTY` enum.
*
* <p>2) Parameter with serialized name {@link ApiRequestParams#EXTRA_PARAMS_KEY} will be
* flattened. This is to support passing new params that the current library has not
* yet supported.
*/
Map<String, Object> convert(ApiRequestParams apiRequestParams) {
JsonObject jsonParams = GSON.toJsonTree(apiRequestParams).getAsJsonObject();
return FLATTENING_EXTRA_PARAMS_DESERIALIZER.deserialize(jsonParams);
}
}
81 changes: 74 additions & 7 deletions src/main/java/com/stripe/net/UntypedMapDeserializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,95 @@
* JSON representation (using GSON) to a generic {@code Map<String, Object>}.
*/
public class UntypedMapDeserializer {
/**
* Strategy to deserialize a JSON element, allowing for custom interactions between the
* deserialized element and its outer map.
* For example, for a full JSON:
* {
* "foo": 1,
* "foo_inner": { // outer context map
* "bar": 1,
* "zing": 2, // given JSON element
* },
* }
*
* <p>Given, a json entry of "zing": 2, the outer map corresponds to value for "foo_inner". A
* default strategy is to simply deserialize value and puts it at "zing" key in the outer map.
*
* <p>Custom strategy allows, for example, renaming the key "zing", wrapping the deserialized
* value in another map/array, or flattening the value if the deserialized value is a map.
*/
interface Strategy {
/**
* Define how the given JSON element should be deserialized, and how the deserialized content
* should be added to the given outer map.
* @param outerMap the untyped map that the deserialized content can be added to.
* @param jsonEntry original JSON entry with key and json element
* @param untypedMapDeserializer deserializer for the untyped map to transform the given json
* element
*/
void deserializeAndTransform(Map<String, Object> outerMap,
Map.Entry<String, JsonElement> jsonEntry,
UntypedMapDeserializer untypedMapDeserializer
);
}

/**
* Strategy for this deserializer.
*/
private Strategy strategy;

/**
* Default deserializer for the untyped map. The result untyped map has same object graph
* structure as that of the given JSON content.
*/
public UntypedMapDeserializer() {
/**
* Default strategy where each JSON element gets deserialized and added with its original key.
*/
this.strategy = new Strategy() {
@Override
public void deserializeAndTransform(Map<String, Object> outerMap,
Map.Entry<String, JsonElement> jsonEntry,
UntypedMapDeserializer untypedMapDeserializer) {
outerMap.put(
jsonEntry.getKey(),
untypedMapDeserializer.deserializeJsonElement(jsonEntry.getValue()));
}
};
}

/**
* Deserializer with a custom strategy.
* @param strategy definition of how JSON element should be deserialized and set in its outer map.
*/
UntypedMapDeserializer(Strategy strategy) {
this.strategy = strategy;
}

/**
* Deserialize JSON into untyped map.
* {@code JsonArray} is represented as {@code List<Object>}.
* {@code JsonObject} is represented as {@code Map<String, Object>}.
* {@code JsonPrimitive} is represented as String, Number, or Boolean.
*
* @param jsonObject JSON to convert into untyped map
* @return untyped map without dependency on JSON representation.
*/
public Map<String, Object> deserialize(JsonObject jsonObject) {
Map<String, Object> objMap = new HashMap<>();
for (Map.Entry<String, JsonElement> entry : jsonObject.entrySet()) {
String key = entry.getKey();
JsonElement element = entry.getValue();
// JsonElement is super class of all JSON standard types:
// array, null, primitive, and object
objMap.put(key, deserializeJsonElement(element));
this.strategy.deserializeAndTransform(objMap, entry, this);
}
return objMap;
}

private Object deserializeJsonElement(JsonElement element) {
/**
* Normalizes JSON element into an untyped Object as value to the untyped map.
* @param element JSON element to convert to java Object
* @return untyped object, one of {@code Map<String, Object>}, {@code String}, {@code Number},
* {@code Boolean}, or {@code List<Array>}.
*/
Object deserializeJsonElement(JsonElement element) {
if (element.isJsonNull()) {
return null;
} else if (element.isJsonObject()) {
Expand Down
Loading