Skip to content

Commit 8d9fcc0

Browse files
Support extra parameters in typed params (#773)
* 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 * validate extra params conflict * [generated] source: spec3.sdk.yaml@spec-6c38dc0 in master (#775) * [generated] source: spec3.sdk.yaml@spec-6c38dc0 in master * add extra params test on invoice * fix docs in untyped map deserializer * suppress warning in test * suppress warnings and add comments in test
1 parent 3ca01c4 commit 8d9fcc0

File tree

246 files changed

+20386
-759
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

246 files changed

+20386
-759
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,148 @@
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+
import com.stripe.param.common.EmptyParam;
16+
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 jsonValue = jsonEntry.getValue();
36+
if (ApiRequestParams.EXTRA_PARAMS_KEY.equals(key)) {
37+
if (!jsonValue.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, jsonValue, 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(jsonValue.getAsJsonObject());
49+
for (Map.Entry<String, Object> entry : extraParamsMap.entrySet()) {
50+
validateDuplicateKey(outerMap, entry.getKey(), entry.getValue());
51+
outerMap.put(entry.getKey(), entry.getValue());
52+
}
53+
} else {
54+
Object value = untypedMapDeserializer.deserializeJsonElement(jsonValue);
55+
validateDuplicateKey(outerMap, key, value);
56+
57+
// Normal deserialization where output map has the same structure as the given JSON content.
58+
// The deserialized content is an untyped `Object` and added to the outer map at the
59+
// original key.
60+
outerMap.put(key, value);
61+
}
62+
}
63+
}
64+
65+
private static void validateDuplicateKey(Map<String, Object> outerMap,
66+
String paramKey, Object paramValue) {
67+
if (outerMap.containsKey(paramKey)) {
68+
throw new IllegalArgumentException(String.format(
69+
"Found multiple param values for the same param key. This can happen because you passed "
70+
+ "additional parameters via `putExtraParam` that conflict with the existing params. "
71+
+ "Found param key `%s` with values `%s` and `%s`. "
72+
+ "If you wish to pass additional params for nested parameters, you "
73+
+ "should add extra params at the nested params themselves, not from the "
74+
+ "top-level param.",
75+
paramKey, outerMap.get(paramKey), paramValue));
76+
}
77+
}
78+
79+
/**
80+
* Type adapter to convert an empty enum to null value to comply with the lower-lever encoding
81+
* logic for the API request parameters.
82+
*/
83+
private static class HasEmptyEnumTypeAdapterFactory implements TypeAdapterFactory {
84+
@SuppressWarnings("unchecked")
85+
@Override
86+
public <T> TypeAdapter<T> create(Gson gson, TypeToken<T> type) {
87+
if (!ApiRequestParams.EnumParam.class.isAssignableFrom(type.getRawType())) {
88+
return null;
89+
}
90+
91+
TypeAdapter<ApiRequestParams.EnumParam> paramEnum =
92+
new TypeAdapter<ApiRequestParams.EnumParam>() {
93+
@Override
94+
public void write(JsonWriter out, ApiRequestParams.EnumParam value) throws IOException {
95+
if (value.getValue().equals("")) {
96+
// need to restore serialize null setting
97+
// not to affect other fields
98+
boolean previousSetting = out.getSerializeNulls();
99+
out.setSerializeNulls(true);
100+
out.nullValue();
101+
out.setSerializeNulls(previousSetting);
102+
} else {
103+
out.value(value.getValue());
104+
}
105+
}
106+
107+
@Override
108+
public ApiRequestParams.EnumParam read(JsonReader in) {
109+
throw new UnsupportedOperationException(
110+
"No deserialization is expected from this private type adapter for enum param.");
111+
}
112+
};
113+
return (TypeAdapter<T>) paramEnum.nullSafe();
114+
}
115+
}
116+
117+
private static final Gson GSON = new GsonBuilder()
118+
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
119+
.registerTypeAdapterFactory(new ApiRequestParamsConverter.HasEmptyEnumTypeAdapterFactory())
120+
.create();
121+
122+
private static final UntypedMapDeserializer FLATTENING_EXTRA_PARAMS_DESERIALIZER =
123+
new UntypedMapDeserializer(new ExtraParamsFlatteningStrategy());
124+
125+
/**
126+
* Convert the given request params into an untyped map. This map is
127+
* composed of {@code Map<String, Object>}, {@code List<Object>}, and basic Java data types.
128+
* This allows you to test building the request params and verify compatibility with your
129+
* prior integrations using the untyped params map
130+
* {@link ApiResource#request(ApiResource.RequestMethod, String, Map, Class, RequestOptions)}.
131+
*
132+
* <p>There are two peculiarities in this conversion:
133+
*
134+
* <p>1) {@link EmptyParam#EMPTY}, containing a raw empty string value, is converted to null.
135+
* This is because the form-encoding layer prohibits passing empty string as a param map value.
136+
* It, however, allows a null value in the map (present key but null value).
137+
* Because of the translation from `EMPTY` enum to null, deserializing this map back to a
138+
* request instance is lossy. The null value will not be converted back to the `EMPTY` enum.
139+
*
140+
* <p>2) Parameter with serialized name {@link ApiRequestParams#EXTRA_PARAMS_KEY} will be
141+
* flattened. This is to support passing new params that the current library has not
142+
* yet supported.
143+
*/
144+
Map<String, Object> convert(ApiRequestParams apiRequestParams) {
145+
JsonObject jsonParams = GSON.toJsonTree(apiRequestParams).getAsJsonObject();
146+
return FLATTENING_EXTRA_PARAMS_DESERIALIZER.deserialize(jsonParams);
147+
}
148+
}

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 interactions between the
22+
* deserialized 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 it at "zing" key in the outer map.
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)