Skip to content
This repository was archived by the owner on Jun 30, 2023. It is now read-only.

Commit 6bb25ed

Browse files
Clément Denistangiel
authored andcommitted
Generate proper API description for Map types using "additionalProperties" (#155)
* Proper Map support for Discovery and Swagger with additionalProperties - Two unit tests disabled (to reenable) - Missing flag to enable new map handling - Changed description handling in SwaggerGenerator to follow spec * Proper Map support for Discovery and Swagger with additionalProperties - Enable new behaviour with a property / env flag - Still use JsonMap for unsupported keys (not String) or value types (arrays) - Add a flag to warn about unsupported Map type parameters - Reenable disabled tests (now passing) - Test old and new behaviour in unit tests * Proper Map support for Discovery and Swagger with additionalProperties - Support additional map key types (confirmed to work at runtime) - Add new flag to enable support for array-like value types - ALlow to use warn flag without actually generating new schema for Maps - Add Javadoc on flags for maps - Additional test cases for Map types * Proper Map support for Discovery and Swagger with additionalProperties - Improve Map schema-related flags with dedicated MapSchemaFlag class - Default to Map schema using "additionalProperties", with flag for legacy mode - Fail by default when encountering non-String Map keys, with flag to ignore - Improve unit tests * Improve readability of MapSchemaFlag * Support more types of Map to generate "additionalProperties" schema: - Handle subclasses of Map with concrete key and value (exclude others) - Exclude raw Map type - Better support nested Maps
1 parent 40d9535 commit 6bb25ed

File tree

18 files changed

+3175
-13
lines changed

18 files changed

+3175
-13
lines changed

endpoints-framework-tools/src/test/java/com/google/api/server/spi/tools/AnnotationApiConfigGeneratorTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1922,7 +1922,7 @@ public void foo(R param) {}
19221922

19231923
@Test
19241924
public void testRequestDoesContainMap() throws Exception {
1925-
checkRequestIsNotEmpty(new SimpleFoo<Map<ServletContext, User>>() {}.getClass());
1925+
checkRequestIsNotEmpty(new SimpleFoo<Map<String, User>>() {}.getClass());
19261926
}
19271927

19281928
@Test
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2018 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.google.api.server.spi.config.model;
17+
18+
import com.google.common.annotations.VisibleForTesting;
19+
20+
/**
21+
* These flags control the behavior of the schema generators regarding Map types.<br>
22+
* <br>
23+
* By default, schema generation uses "additionalProperties" in JsonSchema to describe Map types
24+
* (both for Discovery and OpenAPI), with a proper description of the value types.<br> This mode
25+
* supports key types that can be serialized from / to String, and supports any value type except
26+
* array-like ones (see {@link MapSchemaFlag#SUPPORT_ARRAYS_VALUES} for more details).<br> In
27+
* previous versions of Cloud Endpoints, Maps were always represented using the untyped "JsonMap"
28+
* object (see {@link com.google.api.server.spi.config.model.SchemaRepository#MAP_SCHEMA}).<br>
29+
* <br>
30+
* To enable one of these enum flags, you can either:
31+
* <ul>
32+
* <li>Set system property {@link MapSchemaFlag#systemPropertyName} (defined as
33+
* "endpoints.mapSchema." + systemPropertySuffix) to any value except a falsy one</li>
34+
* <li>Set env variable {@link MapSchemaFlag#envVarName} (defined as "ENDPOINTS_MAP_SCHEMA_"
35+
* + name()) to any value except a falsy one</li>
36+
* </ul>
37+
* <br>
38+
* Notes:
39+
* <ul>
40+
* <li>System properties are evaluated before env variables.</li>
41+
* <li>falsy is defined as a case-insensitive equality with "false".</li>
42+
* </ul>
43+
*/
44+
public enum MapSchemaFlag {
45+
46+
/**
47+
* Reenabled the previous behavior of Cloud Endpoints, using untyped "JsonMap" for all Map types.
48+
*/
49+
FORCE_JSON_MAP_SCHEMA("forceJsonMapSchema"),
50+
51+
/**
52+
* When enabled, schema generation will not throw an error when handling Map types with keys that
53+
* are not serializable from / to string (previous Cloud Endpoints behavior). It will still
54+
* probably generate an error when serializing / deserializing these types at runtime.
55+
*/
56+
IGNORE_UNSUPPORTED_KEY_TYPES("ignoreUnsupportedKeyTypes"),
57+
58+
/**
59+
* Array values in "additionalProperties" are supported by the API Explorer, but not by the Java
60+
* client generation. This flag can be enabled when deploying an API to the server, but should
61+
* always be disabled when generating Java clients.
62+
*/
63+
SUPPORT_ARRAYS_VALUES("supportArrayValues");
64+
65+
private static final String ENV_VARIABLE_PREFIX = "ENDPOINTS_MAP_SCHEMA_";
66+
private static final String SYSTEM_PROPERTY_PREFIX = "endpoints.mapSchema.";
67+
68+
@VisibleForTesting
69+
public String envVarName;
70+
@VisibleForTesting
71+
public String systemPropertyName;
72+
73+
MapSchemaFlag(String systemPropertySuffix) {
74+
this.envVarName = ENV_VARIABLE_PREFIX + name();
75+
this.systemPropertyName = SYSTEM_PROPERTY_PREFIX + systemPropertySuffix;
76+
}
77+
78+
public boolean isEnabled() {
79+
String envVar = System.getenv(envVarName);
80+
String systemProperty = System.getProperty(systemPropertyName);
81+
return systemProperty != null && !"false".equalsIgnoreCase(systemProperty)
82+
|| envVar != null && !"false".equalsIgnoreCase(envVar);
83+
}
84+
85+
}

endpoints-framework/src/main/java/com/google/api/server/spi/config/model/Schema.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ public abstract class Schema {
2121
/** A map from field names to fields for the schema. */
2222
public abstract ImmutableSortedMap<String, Field> fields();
2323

24+
/** If the schema is a map, a reference to the map value type. */
25+
@Nullable public abstract Field mapValueSchema();
26+
2427
/**
2528
* If the schema is an enum, a list of possible enum values in their string representation.
2629
*/
@@ -45,8 +48,9 @@ public abstract static class Builder {
4548

4649
public abstract Builder setName(String name);
4750
public abstract Builder setType(String type);
48-
@Nullable public abstract Builder setDescription(String description);
51+
public abstract Builder setDescription(String description);
4952
public abstract Builder setFields(ImmutableSortedMap<String, Field> fields);
53+
public abstract Builder setMapValueSchema(Field mapValueSchema);
5054
public Builder addField(String name, Field field) {
5155
fieldsBuilder.put(name, field);
5256
return this;
@@ -101,7 +105,7 @@ public static Builder builder() {
101105
public abstract static class Builder {
102106
public abstract Builder setName(String name);
103107
public abstract Builder setType(FieldType type);
104-
@Nullable public abstract Builder setDescription(String description);
108+
public abstract Builder setDescription(String description);
105109
public abstract Builder setSchemaReference(SchemaReference ref);
106110
public abstract Builder setArrayItemSchema(Field schema);
107111
public abstract Field build();

endpoints-framework/src/main/java/com/google/api/server/spi/config/model/SchemaRepository.java

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
import com.google.api.server.spi.config.model.Schema.Field;
1212
import com.google.api.server.spi.config.model.Schema.SchemaReference;
1313
import com.google.common.annotations.VisibleForTesting;
14+
import com.google.common.base.Optional;
1415
import com.google.common.collect.ImmutableList;
1516
import com.google.common.collect.LinkedHashMultimap;
1617
import com.google.common.collect.Multimap;
1718
import com.google.common.reflect.TypeToken;
1819

20+
import java.util.EnumSet;
1921
import java.util.List;
2022
import java.util.Map;
2123
import java.util.Map.Entry;
@@ -41,8 +43,19 @@ public class SchemaRepository {
4143
.setType("object")
4244
.build();
4345

46+
private static final EnumSet<FieldType> SUPPORTED_MAP_KEY_TYPES = EnumSet.of(
47+
FieldType.STRING,
48+
FieldType.ENUM,
49+
FieldType.BOOLEAN,
50+
FieldType.INT8, FieldType.INT16, FieldType.INT32, FieldType.INT64,
51+
FieldType.FLOAT, FieldType.DOUBLE,
52+
FieldType.DATE, FieldType.DATE_TIME
53+
);
54+
4455
@VisibleForTesting
4556
static final String ARRAY_UNUSED_MSG = "unused for array items";
57+
@VisibleForTesting
58+
static final String MAP_UNUSED_MSG = "unused for map values";
4659

4760
private final Multimap<ApiKey, Schema> schemaByApiKeys = LinkedHashMultimap.create();
4861
private final Map<ApiSerializationConfig, Map<TypeToken<?>, Schema>> types = Maps.newHashMap();
@@ -96,8 +109,8 @@ public List<Schema> getAllSchemaForApi(ApiKey apiKey) {
96109
/**
97110
* Gets all schema for an API config.
98111
*
99-
* @return a {@link Map} from {@link TypeToken} to {@link Schema}. If there are no schema for
100-
* this config, an empty map is returned.
112+
* @return a {@link Map} from {@link TypeToken} to {@link Schema}. If there are no schema for this
113+
* config, an empty map is returned.
101114
*/
102115
private Map<TypeToken<?>, Schema> getAllTypesForConfig(ApiConfig config) {
103116
Map<TypeToken<?>, Schema> typesForConfig = types.get(config.getSerializationConfig());
@@ -147,9 +160,16 @@ private Schema getOrCreateTypeForConfig(
147160
schemaByApiKeys.put(key, ANY_SCHEMA);
148161
return ANY_SCHEMA;
149162
} else if (Types.isMapType(type)) {
150-
typesForConfig.put(type, MAP_SCHEMA);
151-
schemaByApiKeys.put(key, MAP_SCHEMA);
152-
return MAP_SCHEMA;
163+
schema = MAP_SCHEMA;
164+
final TypeToken<Map<?, ?>> mapSupertype = type.getSupertype(Map.class);
165+
final boolean hasConcreteKeyValue = Types.isConcreteType(mapSupertype.getType());
166+
boolean forceJsonMapSchema = MapSchemaFlag.FORCE_JSON_MAP_SCHEMA.isEnabled();
167+
if (hasConcreteKeyValue && !forceJsonMapSchema) {
168+
schema = createMapSchema(mapSupertype, typesForConfig, config).or(schema);
169+
}
170+
typesForConfig.put(type, schema);
171+
schemaByApiKeys.put(key, schema);
172+
return schema;
153173
} else if (Types.isEnumType(type)) {
154174
Schema.Builder builder = Schema.builder()
155175
.setName(Types.getSimpleName(type, config.getSerializationConfig()))
@@ -186,6 +206,42 @@ private void addSchemaToApi(ApiKey key, Schema schema) {
186206
addSchemaToApi(key, f.schemaReference().get());
187207
}
188208
}
209+
Field mapValueSchema = schema.mapValueSchema();
210+
if (mapValueSchema != null && mapValueSchema.schemaReference() != null) {
211+
addSchemaToApi(key, mapValueSchema.schemaReference().get());
212+
}
213+
}
214+
215+
private Optional<Schema> createMapSchema(
216+
TypeToken<Map<?, ?>> mapType, Map<TypeToken<?>, Schema> typesForConfig, ApiConfig config) {
217+
FieldType keyFieldType = FieldType.fromType(Types.getTypeParameter(mapType, 0));
218+
boolean supportedKeyType = SUPPORTED_MAP_KEY_TYPES.contains(keyFieldType);
219+
if (!supportedKeyType) {
220+
String message = "Map field type '" + mapType + "' has a key type not serializable to String";
221+
if (MapSchemaFlag.IGNORE_UNSUPPORTED_KEY_TYPES.isEnabled()) {
222+
System.err.println(message + ", its schema will be JsonMap");
223+
} else {
224+
throw new IllegalArgumentException(message);
225+
}
226+
}
227+
TypeToken<?> valueTypeToken = Types.getTypeParameter(mapType, 1);
228+
FieldType valueFieldType = FieldType.fromType(valueTypeToken);
229+
boolean supportArrayValues = MapSchemaFlag.SUPPORT_ARRAYS_VALUES.isEnabled();
230+
boolean supportedValueType = supportArrayValues || valueFieldType != FieldType.ARRAY;
231+
if (!supportedValueType) {
232+
System.err.println("Map field type '" + mapType + "' "
233+
+ "has an array-like value type, its schema will be JsonMap");
234+
}
235+
if (!supportedKeyType || !supportedValueType) {
236+
return Optional.absent();
237+
}
238+
TypeToken<?> valueSchemaType = ApiAnnotationIntrospector.getSchemaType(valueTypeToken, config);
239+
Schema.Builder builder = Schema.builder()
240+
.setName(Types.getSimpleName(mapType, config.getSerializationConfig()))
241+
.setType("object");
242+
Field.Builder fieldBuilder = Field.builder().setName(MAP_UNUSED_MSG);
243+
fillInFieldInformation(fieldBuilder, valueSchemaType, null, typesForConfig, config);
244+
return Optional.of(builder.setMapValueSchema(fieldBuilder.build()).build());
189245
}
190246

191247
private Schema createBeanSchema(
@@ -200,7 +256,8 @@ private Schema createBeanSchema(
200256
TypeToken<?> propertyType = propertySchema.getType();
201257
if (propertyType != null) {
202258
Field.Builder fieldBuilder = Field.builder().setName(propertyName);
203-
fillInFieldInformation(fieldBuilder, propertyType, propertySchema.getDescription(), typesForConfig, config);
259+
fillInFieldInformation(fieldBuilder, propertyType, propertySchema.getDescription(),
260+
typesForConfig, config);
204261
builder.addField(propertyName, fieldBuilder.build());
205262
}
206263
}

endpoints-framework/src/main/java/com/google/api/server/spi/config/model/Types.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,21 @@
2121
import com.google.api.server.spi.config.ResourceTransformer;
2222
import com.google.api.server.spi.config.Transformer;
2323
import com.google.api.server.spi.response.CollectionResponse;
24+
import com.google.common.base.Predicate;
2425
import com.google.common.collect.Iterables;
2526
import com.google.common.reflect.TypeToken;
2627

28+
import java.lang.reflect.GenericArrayType;
2729
import java.lang.reflect.ParameterizedType;
2830
import java.lang.reflect.Type;
2931
import java.lang.reflect.TypeVariable;
3032
import java.lang.reflect.WildcardType;
33+
import java.util.Arrays;
3134
import java.util.Collection;
3235
import java.util.Map;
3336

37+
import javax.annotation.Nullable;
38+
3439
/**
3540
* Utilities for dealing with type information.
3641
*/
@@ -59,6 +64,30 @@ public static boolean isMapType(TypeToken<?> type) {
5964
return type.isSubtypeOf(Map.class) && !isJavaClientEntity(type);
6065
}
6166

67+
/**
68+
* Returns true if this type is not parameterized, or has only concrete type variables (checked
69+
* recursively on parameterized type variables).
70+
*/
71+
public static boolean isConcreteType(Type type) {
72+
if (type instanceof ParameterizedType) {
73+
Type[] typeArguments = ((ParameterizedType) type).getActualTypeArguments();
74+
return Iterables.all(Arrays.asList(typeArguments), new Predicate<Type>() {
75+
@Override
76+
public boolean apply(@Nullable Type input) {
77+
return isConcreteType(input);
78+
}
79+
});
80+
}
81+
if (type instanceof GenericArrayType) {
82+
return isConcreteType(((GenericArrayType) type).getGenericComponentType());
83+
}
84+
if (type instanceof Class) {
85+
return true;
86+
}
87+
//matches instanceof TypeVariable and WildcardType
88+
return false;
89+
}
90+
6291
/**
6392
* Returns whether or not this type is a Google Java client library entity.
6493
*/

endpoints-framework/src/main/java/com/google/api/server/spi/discovery/DiscoveryGenerator.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,9 @@ private JsonSchema convertToDiscoverySchema(Schema schema) {
258258
}
259259
docSchema.setProperties(fields);
260260
}
261+
if (schema.mapValueSchema() != null) {
262+
docSchema.setAdditionalProperties(convertToDiscoverySchema(schema.mapValueSchema()));
263+
}
261264
docSchema.setDescription(schema.description());
262265
if (!schema.enumValues().isEmpty()) {
263266
docSchema.setEnum(new ArrayList<>(schema.enumValues()));

endpoints-framework/src/main/java/com/google/api/server/spi/swagger/SwaggerGenerator.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,9 @@ private Model convertToSwaggerSchema(Schema schema) {
414414
}
415415
docSchema.setProperties(fields);
416416
}
417+
if (schema.mapValueSchema() != null) {
418+
docSchema.setAdditionalProperties(convertToSwaggerProperty(schema.mapValueSchema()));
419+
}
417420
return docSchema;
418421
}
419422

@@ -438,7 +441,10 @@ private Property convertToSwaggerProperty(Field f) {
438441
if (p == null) {
439442
throw new IllegalArgumentException("could not convert field " + f);
440443
}
441-
p.description(f.description());
444+
//the spec explicitly disallows description on $ref
445+
if (!(p instanceof RefProperty)) {
446+
p.description(f.description());
447+
}
442448
return p;
443449
}
444450

0 commit comments

Comments
 (0)