Skip to content

Commit ed1a19e

Browse files
Support extra parameters in typed params (#752)
* allow custom strategy within the package * package-level params converter * test create query with encoded param keys * improve comments
1 parent 3ca01c4 commit ed1a19e

6 files changed

+503
-75
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,5 @@
11
package com.stripe.net;
22

3-
import com.google.gson.FieldNamingPolicy;
4-
import com.google.gson.Gson;
5-
import com.google.gson.GsonBuilder;
6-
import com.google.gson.JsonObject;
7-
import com.google.gson.TypeAdapter;
8-
import com.google.gson.TypeAdapterFactory;
9-
import com.google.gson.reflect.TypeToken;
10-
import com.google.gson.stream.JsonReader;
11-
import com.google.gson.stream.JsonWriter;
12-
13-
import java.io.IOException;
143
import java.util.Map;
154

165
/**
@@ -23,70 +12,34 @@ public abstract class ApiRequestParams {
2312
/**
2413
* Interface implemented by all enum parameter to get the actual string value that Stripe API
2514
* expects. Internally, it used in custom serialization
26-
* {@link ApiRequestParams.HasEmptyEnumTypeAdapterFactory} converting empty string enum to null.
15+
* {@link ApiRequestParamsConverter} converting empty string enum to
16+
* null.
2717
*/
2818
public interface EnumParam {
2919
String getValue();
3020
}
3121

32-
private static final Gson GSON = new GsonBuilder()
33-
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
34-
.registerTypeAdapterFactory(new HasEmptyEnumTypeAdapterFactory())
35-
.create();
36-
37-
private static final UntypedMapDeserializer UNTYPED_MAP_DESERIALIZER =
38-
new UntypedMapDeserializer();
39-
40-
private static class HasEmptyEnumTypeAdapterFactory implements TypeAdapterFactory {
41-
@SuppressWarnings("unchecked")
42-
@Override
43-
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
44-
if (!EnumParam.class.isAssignableFrom(type.getRawType())) {
45-
return null;
46-
}
47-
48-
TypeAdapter<EnumParam> paramEnum = new TypeAdapter<EnumParam>() {
49-
@Override
50-
public void write(JsonWriter out, EnumParam value) throws IOException {
51-
if (value.getValue().equals("")) {
52-
// need to restore serialize null setting
53-
// not to affect other fields
54-
boolean previousSetting = out.getSerializeNulls();
55-
out.setSerializeNulls(true);
56-
out.nullValue();
57-
out.setSerializeNulls(previousSetting);
58-
} else {
59-
out.value(value.getValue());
60-
}
61-
}
22+
/**
23+
* Param key for an `extraParams` map. Any param/sub-param specifying a field
24+
* intended to support extra params from users should have the annotation
25+
* {@code @SerializedName(ApiRequestParams.EXTRA_PARAMS_KEY)}. Logic to handle this is in
26+
* {@link ApiRequestParamsConverter}.
27+
*/
28+
public static final String EXTRA_PARAMS_KEY = "_stripe_java_extra_param_key";
6229

63-
@Override
64-
public EnumParam read(JsonReader in) {
65-
throw new UnsupportedOperationException(
66-
"No deserialization is expected from this private type adapter for enum param.");
67-
}
68-
};
69-
return (TypeAdapter<T>) paramEnum.nullSafe();
70-
}
71-
}
30+
/**
31+
* Converter mapping typed API request parameters into an untyped map.
32+
*/
33+
private static final ApiRequestParamsConverter PARAMS_CONVERTER =
34+
new ApiRequestParamsConverter();
7235

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

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package com.stripe.net;
2+
3+
import com.google.gson.FieldNamingPolicy;
4+
import com.google.gson.Gson;
5+
import com.google.gson.GsonBuilder;
6+
import com.google.gson.JsonElement;
7+
import com.google.gson.JsonObject;
8+
import com.google.gson.TypeAdapter;
9+
import com.google.gson.TypeAdapterFactory;
10+
import com.google.gson.reflect.TypeToken;
11+
import com.google.gson.stream.JsonReader;
12+
import com.google.gson.stream.JsonWriter;
13+
14+
import com.stripe.Stripe;
15+
16+
import com.stripe.param.common.EmptyParam;
17+
import java.io.IOException;
18+
import java.util.Map;
19+
20+
/**
21+
* Converter to map an api request object to an untyped map.
22+
* It is not called a *Serializer because the outcome is not a JSON data.
23+
* It is not called *UntypedMapDeserializer because it is not converting from JSON.
24+
*/
25+
class ApiRequestParamsConverter {
26+
/**
27+
* Strategy to flatten extra params in the API request parameters.
28+
*/
29+
private static class ExtraParamsFlatteningStrategy implements UntypedMapDeserializer.Strategy {
30+
@Override
31+
public void deserializeAndTransform(Map<String, Object> outerMap,
32+
Map.Entry<String, JsonElement> jsonEntry,
33+
UntypedMapDeserializer untypedMapDeserializer) {
34+
String key = jsonEntry.getKey();
35+
JsonElement value = jsonEntry.getValue();
36+
if (ApiRequestParams.EXTRA_PARAMS_KEY.equals(key)) {
37+
if (!value.isJsonObject()) {
38+
throw new IllegalStateException(String.format(
39+
"Unexpected schema for extra params. JSON object is expected at key `%s`, but found"
40+
+ " `%s`. This is likely a problem with this current library version `%s`. "
41+
+ "Please contact [email protected] for assistance.",
42+
ApiRequestParams.EXTRA_PARAMS_KEY, value, Stripe.VERSION));
43+
}
44+
// JSON value now corresponds to the extra params map, and is also deserialized as a map.
45+
// Instead of putting this result map under the original key, flatten the map
46+
// by adding all its key/value pairs to the outer map instead.
47+
Map<String, Object> extraParamsMap =
48+
untypedMapDeserializer.deserialize(value.getAsJsonObject());
49+
outerMap.putAll(extraParamsMap);
50+
} else {
51+
// Normal deserialization where output map has the same structure as the given JSON content.
52+
// The deserialized content is an untyped `Object` and added to the outer map at the
53+
// original key.
54+
outerMap.put(key, untypedMapDeserializer.deserializeJsonElement(value));
55+
}
56+
}
57+
}
58+
59+
/**
60+
* Type adapter to convert an empty enum to null value to comply with the lower-lever encoding
61+
* logic for the API request parameters.
62+
*/
63+
private static class HasEmptyEnumTypeAdapterFactory implements TypeAdapterFactory {
64+
@SuppressWarnings("unchecked")
65+
@Override
66+
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
67+
if (!ApiRequestParams.EnumParam.class.isAssignableFrom(type.getRawType())) {
68+
return null;
69+
}
70+
71+
TypeAdapter<ApiRequestParams.EnumParam> paramEnum =
72+
new TypeAdapter<ApiRequestParams.EnumParam>() {
73+
@Override
74+
public void write(JsonWriter out, ApiRequestParams.EnumParam value) throws IOException {
75+
if (value.getValue().equals("")) {
76+
// need to restore serialize null setting
77+
// not to affect other fields
78+
boolean previousSetting = out.getSerializeNulls();
79+
out.setSerializeNulls(true);
80+
out.nullValue();
81+
out.setSerializeNulls(previousSetting);
82+
} else {
83+
out.value(value.getValue());
84+
}
85+
}
86+
87+
@Override
88+
public ApiRequestParams.EnumParam read(JsonReader in) {
89+
throw new UnsupportedOperationException(
90+
"No deserialization is expected from this private type adapter for enum param.");
91+
}
92+
};
93+
return (TypeAdapter<T>) paramEnum.nullSafe();
94+
}
95+
}
96+
97+
private static final Gson GSON = new GsonBuilder()
98+
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
99+
.registerTypeAdapterFactory(new ApiRequestParamsConverter.HasEmptyEnumTypeAdapterFactory())
100+
.create();
101+
102+
private static final UntypedMapDeserializer FLATTENING_EXTRA_PARAMS_DESERIALIZER =
103+
new UntypedMapDeserializer(new ExtraParamsFlatteningStrategy());
104+
105+
/**
106+
* Convert the given request params into an untyped map. This map is
107+
* composed of {@code Map<String, Object>}, {@code List<Object>}, and basic Java data types.
108+
* This allows you to test building the request params and verify compatibility with your
109+
* prior integrations using the untyped params map
110+
* {@link ApiResource#request(ApiResource.RequestMethod, String, Map, Class, RequestOptions)}.
111+
*
112+
* <p>There are two peculiarities in this conversion:
113+
*
114+
* <p>1) {@link EmptyParam#EMPTY}, containing a raw empty string value, is converted to null.
115+
* This is because the form-encoding layer prohibits passing empty string as a param map value.
116+
* It, however, allows a null value in the map (present key but null value).
117+
* Because of the translation from `EMPTY` enum to null, deserializing this map back to a
118+
* request instance is lossy. The null value will not be converted back to the `EMPTY` enum.
119+
*
120+
* <p>2) Parameter with serialized name {@link ApiRequestParams#EXTRA_PARAMS_KEY} will be
121+
* flattened. This is to support passing new params that the current library has not
122+
* yet supported.
123+
*/
124+
Map<String, Object> convert(ApiRequestParams apiRequestParams) {
125+
JsonObject jsonParams = GSON.toJsonTree(apiRequestParams).getAsJsonObject();
126+
return FLATTENING_EXTRA_PARAMS_DESERIALIZER.deserialize(jsonParams);
127+
}
128+
}

src/main/java/com/stripe/net/UntypedMapDeserializer.java

+74-7
Original file line numberDiff line numberDiff line change
@@ -17,28 +17,95 @@
1717
* JSON representation (using GSON) to a generic {@code Map<String, Object>}.
1818
*/
1919
public class UntypedMapDeserializer {
20+
/**
21+
* Strategy to deserialize a JSON element, allowing for custom behavior between the deserialized
22+
* element and its outer map.
23+
* For example, for a full JSON:
24+
* {
25+
* "foo": 1,
26+
* "foo_inner": { // outer context map
27+
* "bar": 1,
28+
* "zing": 2, // given JSON element
29+
* },
30+
* }
31+
*
32+
* <p>Given, a json entry of "zing": 2, the outer map corresponds to value for "foo_inner". A
33+
* default strategy is to simply deserialize value and puts to the outer map at "zing" key.
34+
*
35+
* <p>Custom strategy allows, for example, renaming the key "zing", wrapping the deserialized
36+
* value in another map/array, or flattening the value if the deserialized value is a map.
37+
*/
38+
interface Strategy {
39+
/**
40+
* Define how the given JSON element should be deserialized, and how the deserialized content
41+
* should be added to the given outer map.
42+
* @param outerMap the untyped map that the deserialized content can be added to.
43+
* @param jsonEntry original JSON entry with key and json element
44+
* @param untypedMapDeserializer deserializer for the untyped map to transform the given json
45+
* element
46+
*/
47+
void deserializeAndTransform(Map<String, Object> outerMap,
48+
Map.Entry<String, JsonElement> jsonEntry,
49+
UntypedMapDeserializer untypedMapDeserializer
50+
);
51+
}
52+
53+
/**
54+
* Strategy for this deserializer.
55+
*/
56+
private Strategy strategy;
57+
58+
/**
59+
* Default deserializer for the untyped map. The result untyped map has same object graph
60+
* structure as that of the given JSON content.
61+
*/
62+
public UntypedMapDeserializer() {
63+
/**
64+
* Default strategy where each JSON element gets deserialized and added with its original key.
65+
*/
66+
this.strategy = new Strategy() {
67+
@Override
68+
public void deserializeAndTransform(Map<String, Object> outerMap,
69+
Map.Entry<String, JsonElement> jsonEntry,
70+
UntypedMapDeserializer untypedMapDeserializer) {
71+
outerMap.put(
72+
jsonEntry.getKey(),
73+
untypedMapDeserializer.deserializeJsonElement(jsonEntry.getValue()));
74+
}
75+
};
76+
}
77+
78+
/**
79+
* Deserializer with a custom strategy.
80+
* @param strategy definition of how JSON element should be deserialized and set in its outer map.
81+
*/
82+
UntypedMapDeserializer(Strategy strategy) {
83+
this.strategy = strategy;
84+
}
85+
2086
/**
2187
* Deserialize JSON into untyped map.
2288
* {@code JsonArray} is represented as {@code List<Object>}.
2389
* {@code JsonObject} is represented as {@code Map<String, Object>}.
2490
* {@code JsonPrimitive} is represented as String, Number, or Boolean.
25-
*
2691
* @param jsonObject JSON to convert into untyped map
2792
* @return untyped map without dependency on JSON representation.
2893
*/
2994
public Map<String, Object> deserialize(JsonObject jsonObject) {
3095
Map<String, Object> objMap = new HashMap<>();
3196
for (Map.Entry<String, JsonElement> entry : jsonObject.entrySet()) {
32-
String key = entry.getKey();
33-
JsonElement element = entry.getValue();
34-
// JsonElement is super class of all JSON standard types:
35-
// array, null, primitive, and object
36-
objMap.put(key, deserializeJsonElement(element));
97+
this.strategy.deserializeAndTransform(objMap, entry, this);
3798
}
3899
return objMap;
39100
}
40101

41-
private Object deserializeJsonElement(JsonElement element) {
102+
/**
103+
* Normalizes JSON element into an untyped Object as value to the untyped map.
104+
* @param element JSON element to convert to java Object
105+
* @return untyped object, one of {@code Map<String, Object>}, {@code String}, {@code Number},
106+
* {@code Boolean}, or {@code List<Array>}.
107+
*/
108+
Object deserializeJsonElement(JsonElement element) {
42109
if (element.isJsonNull()) {
43110
return null;
44111
} else if (element.isJsonObject()) {

0 commit comments

Comments
 (0)