com.google.api.server.spi.config.jsonwriter.JsonConfigWriter.java Source code

Java tutorial

Introduction

Here is the source code for com.google.api.server.spi.config.jsonwriter.JsonConfigWriter.java

Source

/*
 * Copyright 2016 Google Inc. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.api.server.spi.config.jsonwriter;

import com.google.api.server.spi.Constant;
import com.google.api.server.spi.EndpointMethod;
import com.google.api.server.spi.ObjectMapperUtil;
import com.google.api.server.spi.TypeLoader;
import com.google.api.server.spi.config.ApiConfigException;
import com.google.api.server.spi.config.ApiConfigWriter;
import com.google.api.server.spi.config.ResourcePropertySchema;
import com.google.api.server.spi.config.ResourceSchema;
import com.google.api.server.spi.config.annotationreader.ApiAnnotationIntrospector;
import com.google.api.server.spi.config.model.ApiAuthConfig;
import com.google.api.server.spi.config.model.ApiCacheControlConfig;
import com.google.api.server.spi.config.model.ApiConfig;
import com.google.api.server.spi.config.model.ApiFrontendLimitsConfig;
import com.google.api.server.spi.config.model.ApiKey;
import com.google.api.server.spi.config.model.ApiMethodConfig;
import com.google.api.server.spi.config.model.ApiNamespaceConfig;
import com.google.api.server.spi.config.model.ApiParameterConfig;
import com.google.api.server.spi.config.model.SchemaRepository;
import com.google.api.server.spi.config.model.Types;
import com.google.api.server.spi.config.scope.AuthScopeExpressions;
import com.google.api.server.spi.config.validation.ApiConfigValidator;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Function;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import com.google.common.collect.Multimaps;
import com.google.common.reflect.TypeToken;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import java.io.IOException;
import java.lang.reflect.Method;
import java.lang.reflect.TypeVariable;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.annotation.Nullable;

/**
 * Writer of legacy Endpoints API configurations.
 */
public class JsonConfigWriter implements ApiConfigWriter {
    /**
     * Default deadline to set in lily adapters. Use a default deadline for Swarm APIs that is
     * slightly larger than the GAE request processing limits (60 seconds). This value is also used as
     * the Harpoon service deadline when we reach BEs outside Google.
     */
    private static final double DEFAULT_LILY_DEADLINE = 65.0;

    public static final String MAP_SCHEMA_NAME = "JsonMap";
    public static final String ANY_SCHEMA_NAME = "_any";

    private final TypeLoader typeLoader;
    private final ApiConfigValidator validator;

    private final ResourceSchemaProvider resourceSchemaProvider = new JacksonResourceSchemaProvider();

    public JsonConfigWriter() throws ClassNotFoundException {
        this.typeLoader = new TypeLoader(JsonConfigWriter.class.getClassLoader());
        this.validator = new ApiConfigValidator(typeLoader, new SchemaRepository(typeLoader));
    }

    public JsonConfigWriter(TypeLoader typeLoader, ApiConfigValidator validator) throws ClassNotFoundException {
        this.typeLoader = typeLoader;
        this.validator = validator;
    }

    private static final ObjectMapper objectMapper = ObjectMapperUtil.createStandardObjectMapper();

    @Override
    public Map<ApiKey, String> writeConfig(Iterable<? extends ApiConfig> configs) throws ApiConfigException {
        Multimap<ApiKey, ? extends ApiConfig> apisByKey = Multimaps.index(configs,
                new Function<ApiConfig, ApiKey>() {
                    @Override
                    public ApiKey apply(ApiConfig config) {
                        return config.getApiKey();
                    }
                });

        // This *must* retain the order of apisByKey so the lily_java_api BUILD rule has predictable
        // output order.
        Map<ApiKey, String> results = Maps.newLinkedHashMap();
        for (ApiKey apiKey : apisByKey.keySet()) {
            Collection<? extends ApiConfig> apiConfigs = apisByKey.get(apiKey);
            validator.validate(apiConfigs);
            results.put(apiKey, generateForApi(apiConfigs));
        }
        return results;
    }

    @Override
    public String getFileExtension() {
        return "api";
    }

    private String generateForApi(Iterable<? extends ApiConfig> apiConfigs) throws ApiConfigException {
        ObjectNode root = objectMapper.createObjectNode();
        // First, generate api-wide configuration options, given any ApiConfig.
        ApiConfig apiConfig = Iterables.get(apiConfigs, 0);
        convertApi(root, apiConfig);
        convertApiAuth(root, apiConfig.getAuthConfig());
        convertApiFrontendLimits(root, apiConfig.getFrontendLimitsConfig());
        convertApiCacheControl(root, apiConfig.getCacheControlConfig());
        convertApiNamespace(root, apiConfig.getNamespaceConfig());
        // Next, generate config-specific configuration options,
        convertApiMethods(apiConfigs, root);
        return toString(root);
    }

    /** Writes an object node as a string. */
    private String toString(ObjectNode node) throws ApiConfigException {
        try {
            return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(node);
        } catch (IOException e) {
            throw new ApiConfigException(e);
        }
    }

    private void setNodePropertyNoConflict(ObjectNode node, String key, JsonNode value, String errorMessage) {
        if (!node.path(key).isMissingNode()) {
            throw new IllegalArgumentException(errorMessage);
        }
        node.set(key, value);
    }

    private void setNodePropertyNoConflict(ObjectNode node, String key, JsonNode value) {
        setNodePropertyNoConflict(node, key, value, "Multiple values for same key '" + key + "'");
    }

    private void convertApi(ObjectNode root, ApiConfig config) {
        root.put("extends", getParentApiFile());
        root.put("abstract", config.getIsAbstract());
        root.put("root", config.getRoot());
        root.put("name", config.getName());
        if (config.getCanonicalName() != null) {
            root.put("canonicalName", config.getCanonicalName());
        }
        root.put("version", config.getVersion());
        if (config.getTitle() != null) {
            root.put("title", config.getTitle());
        }
        if (config.getDescription() != null) {
            root.put("description", config.getDescription());
        }
        if (config.getDocumentationLink() != null) {
            root.put("documentation", config.getDocumentationLink());
        }
        root.put("defaultVersion", config.getIsDefaultVersion());
        ArrayNode discovery = objectMapper.createArrayNode();
        discovery.add(config.getIsDiscoverable() ? "PUBLIC" : "OFF");
        root.set("discovery", discovery);

        ObjectNode adapter = objectMapper.createObjectNode();
        adapter.put("bns", config.getBackendRoot());
        adapter.put("deadline", DEFAULT_LILY_DEADLINE);
        adapter.put("type", "lily");
        root.set("adapter", adapter);
    }

    /**
     * Returns the name of the file the config should extend. Subclasses may
     * override to use their own api file.
     */
    protected String getParentApiFile() {
        return "thirdParty.api";
    }

    /**
     * Converts the auth config from the auth annotation. Subclasses may override
     * to add additional information to the auth config.
     */
    private void convertApiAuth(ObjectNode root, ApiAuthConfig config) {
        ObjectNode authConfig = objectMapper.createObjectNode();

        authConfig.put("allowCookieAuth", config.getAllowCookieAuth());

        List<String> blockedRegions = config.getBlockedRegions();
        if (!blockedRegions.isEmpty()) {
            ArrayNode blockedRegionsNode = objectMapper.createArrayNode();
            for (String region : blockedRegions) {
                blockedRegionsNode.add(region);
            }
            authConfig.set("blockedRegions", blockedRegionsNode);
        }

        root.set("auth", authConfig);
    }

    private void convertApiFrontendLimits(ObjectNode root, ApiFrontendLimitsConfig config) {
        ObjectNode frontendLimitsConfig = objectMapper.createObjectNode();

        frontendLimitsConfig.put("unregisteredUserQps", config.getUnregisteredUserQps());
        frontendLimitsConfig.put("unregisteredQps", config.getUnregisteredQps());
        frontendLimitsConfig.put("unregisteredDaily", config.getUnregisteredDaily());

        convertApiFrontendLimitRules(frontendLimitsConfig, config.getRules());

        root.set("frontendLimits", frontendLimitsConfig);
    }

    private void convertApiCacheControl(ObjectNode root, ApiCacheControlConfig config) {
        ObjectNode cacheControlConfig = objectMapper.createObjectNode();
        cacheControlConfig.put("type", config.getType());
        cacheControlConfig.put("maxAge", config.getMaxAge());
        root.set("cacheControl", cacheControlConfig);
    }

    private void convertApiNamespace(ObjectNode root, ApiNamespaceConfig config) {
        if (!config.getOwnerDomain().isEmpty()) {
            root.put("ownerDomain", config.getOwnerDomain());
        }
        if (!config.getOwnerName().isEmpty()) {
            root.put("ownerName", config.getOwnerName());
        }
        if (!config.getPackagePath().isEmpty()) {
            root.put("packagePath", config.getPackagePath());
        }
    }

    private void convertApiFrontendLimitRules(ObjectNode frontendLimitsConfig,
            List<ApiFrontendLimitsConfig.FrontendLimitsRule> rules) {
        ArrayNode rulesConfig = objectMapper.createArrayNode();
        for (ApiFrontendLimitsConfig.FrontendLimitsRule rule : rules) {
            // TODO: Allow overriding individual rules based on same "match" field?
            ObjectNode ruleConfig = objectMapper.createObjectNode();
            ruleConfig.put("match", rule.getMatch());
            ruleConfig.put("qps", rule.getQps());
            ruleConfig.put("userQps", rule.getUserQps());
            ruleConfig.put("daily", rule.getDaily());
            ruleConfig.put("analyticsId", rule.getAnalyticsId());
            rulesConfig.add(ruleConfig);
        }
        frontendLimitsConfig.set("rules", rulesConfig);
    }

    private void convertApiMethods(Iterable<? extends ApiConfig> configs, ObjectNode root)
            throws IllegalArgumentException, SecurityException, ApiConfigException {
        ObjectNode methodsNode = objectMapper.createObjectNode();
        ObjectNode descriptorNode = objectMapper.createObjectNode();
        ObjectNode descriptorSchemasNode = objectMapper.createObjectNode();
        ObjectNode descriptorMethodsNode = objectMapper.createObjectNode();
        descriptorNode.set("schemas", descriptorSchemasNode);
        descriptorNode.set("methods", descriptorMethodsNode);

        for (ApiConfig config : configs) {
            convertApiMethods(methodsNode, descriptorSchemasNode, descriptorMethodsNode, config);
        }

        root.set("methods", methodsNode);
        root.set("descriptor", descriptorNode);
    }

    private void convertApiMethods(ObjectNode methodsNode, ObjectNode descriptorSchemasNode,
            ObjectNode descriptorMethodsNode, ApiConfig apiConfig)
            throws IllegalArgumentException, SecurityException, ApiConfigException {
        Map<EndpointMethod, ApiMethodConfig> methodConfigs = apiConfig.getApiClassConfig().getMethods();
        for (Map.Entry<EndpointMethod, ApiMethodConfig> methodConfig : methodConfigs.entrySet()) {
            if (!methodConfig.getValue().isIgnored()) {
                EndpointMethod endpointMethod = methodConfig.getKey();
                ApiMethodConfig config = methodConfig.getValue();
                convertApiMethod(methodsNode, descriptorSchemasNode, descriptorMethodsNode, endpointMethod, config,
                        apiConfig);
            }
        }
    }

    private void convertApiMethod(ObjectNode methodsNode, ObjectNode descriptorSchemasNode,
            ObjectNode descriptorMethodsNode, EndpointMethod endpointMethod, ApiMethodConfig config,
            ApiConfig apiConfig) throws IllegalArgumentException, SecurityException, ApiConfigException {
        ObjectNode methodNode = objectMapper.createObjectNode();
        setNodePropertyNoConflict(methodsNode, config.getFullMethodName(), methodNode);

        methodNode.put("path", config.getPath());
        methodNode.put("description", config.getDescription());
        methodNode.put("httpMethod", config.getHttpMethod());

        methodNode.set("authLevel", objectMapper.convertValue(config.getAuthLevel(), JsonNode.class));
        methodNode.set("scopes", objectMapper.convertValue(AuthScopeExpressions.encode(config.getScopeExpression()),
                JsonNode.class));
        methodNode.set("audiences", objectMapper.convertValue(config.getAudiences(), JsonNode.class));
        methodNode.set("clientIds", objectMapper.convertValue(config.getClientIds(), JsonNode.class));
        methodNode.put("rosyMethod", config.getFullJavaName());

        ObjectNode descriptorMethodNode = objectMapper.createObjectNode();
        setNodePropertyNoConflict(descriptorMethodsNode, config.getFullJavaName(), descriptorMethodNode);

        convertMethodRequest(endpointMethod, methodNode, descriptorSchemasNode, descriptorMethodNode, config,
                apiConfig);
        convertMethodResponse(endpointMethod, methodNode, descriptorSchemasNode, descriptorMethodNode, config);
    }

    private void convertMethodRequest(EndpointMethod endpointMethod, ObjectNode apiMethodNode,
            ObjectNode descriptorSchemasNode, ObjectNode descriptorMethodNode, ApiMethodConfig config,
            ApiConfig apiConfig) throws IllegalArgumentException, SecurityException, ApiConfigException {
        ObjectNode requestNode = objectMapper.createObjectNode();

        convertMethodRequestParameters(endpointMethod, requestNode, descriptorSchemasNode, descriptorMethodNode,
                config, apiConfig);

        apiMethodNode.set("request", requestNode);
    }

    private void convertMethodRequestParameters(EndpointMethod endpointMethod, ObjectNode requestNode,
            ObjectNode descriptorSchemasNode, ObjectNode descriptorMethodNode, ApiMethodConfig config,
            ApiConfig apiConfig) throws IllegalArgumentException, SecurityException, ApiConfigException {
        ObjectNode parametersNode = objectMapper.createObjectNode();
        Method method = endpointMethod.getMethod();
        List<ApiParameterConfig> parameterConfigs = config.getParameterConfigs();

        for (ApiParameterConfig parameterConfig : parameterConfigs) {
            switch (parameterConfig.getClassification()) {
            case INJECTED:
                // Do nothing.
                break;
            case API_PARAMETER:
                convertSimpleParameter(parameterConfig, parametersNode);
                break;
            case RESOURCE:
                // Inserts resource in.
                convertComplexParameter(parameterConfig, method, descriptorSchemasNode, descriptorMethodNode,
                        apiConfig, parameterConfigs);
                break;
            case UNKNOWN:
                throw new IllegalArgumentException("Unclassifiable parameter type found.");
            }
        }
        // Set API parameter types if needed.
        if (parametersNode.size() != 0) {
            requestNode.set("parameters", parametersNode);
        }
        // Sets request body to auto-template if Lily request portion is set..
        if (descriptorMethodNode.get("request") != null) {
            requestNode.put("body", "autoTemplate(backendRequest)");
            requestNode.put("bodyName", "resource");
        } else {
            requestNode.put("body", "empty");
        }
    }

    /*
     * This appends to the API file any type which can go into the methods.request.parameters
     * portion of the resulting .api file. This includes parameter types and any type, such as
     * enum or lists of simple types, which can be converted into simple types.
     */
    private void convertSimpleParameter(ApiParameterConfig config, ObjectNode parametersNode) {
        ObjectNode parameterNode = objectMapper.createObjectNode();

        TypeToken<?> type;
        if (config.isRepeated()) {
            parameterNode.put("repeated", true);
            type = config.getRepeatedItemSerializedType();
        } else {
            type = config.getSchemaBaseType();
        }

        if (config.isEnum()) {
            ObjectNode enumValuesNode = objectMapper.createObjectNode();
            for (Object enumConstant : type.getRawType().getEnumConstants()) {
                ObjectNode enumNode = objectMapper.createObjectNode();
                enumValuesNode.set(enumConstant.toString(), enumNode);
            }
            parameterNode.set("enum", enumValuesNode);

            type = TypeToken.of(String.class);
        }

        parameterNode.put("type", typeLoader.getParameterTypes().get(type.getRawType()));
        parameterNode.put("description", config.getDescription());
        parameterNode.put("required", !config.getNullable() && config.getDefaultValue() == null);

        // TODO: Try to find a way to move default value interpretation/conversion into the
        // general configuration code.
        String defaultValue = config.getDefaultValue();
        if (defaultValue != null) {
            Class<?> parameterClass = type.getRawType();
            try {
                objectMapper.convertValue(defaultValue, parameterClass);
            } catch (IllegalArgumentException e) {
                throw new IllegalArgumentException(
                        String.format("'%s' is not a valid default value for type '%s'", defaultValue, type));
            }
            parameterNode.put("default", defaultValue);
        }

        parametersNode.set(config.getName(), parameterNode);
    }

    /*
     * This appends to the API file any type which can NOT go into the methods.request.parameters.
     * These will be present as Json schemas inside the Lily descriptor portion of the API file.
     */
    private void convertComplexParameter(ApiParameterConfig config, Method method, ObjectNode descriptorSchemasNode,
            ObjectNode descriptorMethodNode, ApiConfig apiConfig, List<ApiParameterConfig> parameterConfigs)
            throws ApiConfigException {
        TypeToken<?> type = config.getSchemaBaseType();
        ObjectNode requestTypeNode = objectMapper.createObjectNode();
        addTypeToNode(descriptorSchemasNode, type, null, requestTypeNode, apiConfig, parameterConfigs);

        setNodePropertyNoConflict(descriptorMethodNode, "request", requestTypeNode,
                "Method " + method.getDeclaringClass().getName() + "." + method.getName()
                        + " cannot have multiple resource parameters");
    }

    private void convertMethodResponse(EndpointMethod serviceMethod, ObjectNode methodNode,
            ObjectNode descriptorSchemasNode, ObjectNode descriptorMethodNode, ApiMethodConfig config)
            throws ApiConfigException {
        ObjectNode responseNode = objectMapper.createObjectNode();
        methodNode.set("response", responseNode);
        if (config.hasResourceInResponse()) {
            responseNode.put("body", "autoTemplate(backendResponse)");

            // TODO: Get from ApiMethodConfig.
            TypeToken<?> returnType = ApiAnnotationIntrospector.getSchemaType(serviceMethod.getReturnType(),
                    config.getApiClassConfig().getApiConfig());
            descriptorMethodNode.set("response",
                    convertMethodResponseType(descriptorSchemasNode, returnType, config));
        } else {
            // Void methods don't generate response sections in the descriptor
            responseNode.put("body", "empty");
        }
    }

    /**
     * Returns a node with the response object type, wrapping any arrays into a new Collection schema.
     */
    private ObjectNode convertMethodResponseType(ObjectNode descriptorSchemasNode, TypeToken<?> returnType,
            ApiMethodConfig config) throws ApiConfigException {
        ObjectNode returnTypeNode = objectMapper.createObjectNode();
        String responseTypeName = addTypeToNode(descriptorSchemasNode, returnType, null, returnTypeNode,
                config.getApiClassConfig().getApiConfig(), null);

        if (Types.isArrayType(returnType)) {
            ObjectNode propertiesNode = objectMapper.createObjectNode();
            propertiesNode.set("items", returnTypeNode);

            ObjectNode arrayWrapperNode = objectMapper.createObjectNode();
            arrayWrapperNode.put("id", responseTypeName);
            arrayWrapperNode.put("type", "object");
            arrayWrapperNode.set("properties", propertiesNode);
            descriptorSchemasNode.set(responseTypeName, arrayWrapperNode);

            returnTypeNode = objectMapper.createObjectNode();
            returnTypeNode.put("$ref", responseTypeName);
        }
        return returnTypeNode;
    }

    @VisibleForTesting
    String addTypeToSchema(ObjectNode schemasNode, TypeToken<?> type, ApiConfig apiConfig,
            List<ApiParameterConfig> parameterConfigs) throws ApiConfigException {
        return addTypeToSchema(schemasNode, type, null, apiConfig, parameterConfigs);
    }

    /**
     * Adds an arbitrary, non-array type to a given schema config.
     *
     * @param schemasNode the config to store the generated type schema
     * @param type the type from which to generate a schema
     * @param enclosingType for bean properties, the enclosing bean type, used for resolving type
     *        variables
     * @return the name of the schema generated from the type
     */
    @VisibleForTesting
    String addTypeToSchema(ObjectNode schemasNode, TypeToken<?> type, TypeToken<?> enclosingType,
            ApiConfig apiConfig, List<ApiParameterConfig> parameterConfigs) throws ApiConfigException {
        if (typeLoader.isSchemaType(type)) {
            return typeLoader.getSchemaType(type);
        } else if (Types.isObject(type)) {
            if (!schemasNode.has(ANY_SCHEMA_NAME)) {
                ObjectNode anySchema = objectMapper.createObjectNode();
                anySchema.put("id", ANY_SCHEMA_NAME);
                anySchema.put("type", "any");
                schemasNode.set(ANY_SCHEMA_NAME, anySchema);
            }
            return ANY_SCHEMA_NAME;
        } else if (Types.isMapType(type)) {
            if (!schemasNode.has(MAP_SCHEMA_NAME)) {
                ObjectNode mapSchema = objectMapper.createObjectNode();
                mapSchema.put("id", MAP_SCHEMA_NAME);
                mapSchema.put("type", "object");
                schemasNode.set(MAP_SCHEMA_NAME, mapSchema);
            }
            return MAP_SCHEMA_NAME;
        }

        // If we already have this schema defined, don't define it again!
        String typeName = Types.getSimpleName(type, apiConfig.getSerializationConfig());
        JsonNode existing = schemasNode.get(typeName);
        if (existing != null && existing.isObject()) {
            return typeName;
        }

        ObjectNode schemaNode = objectMapper.createObjectNode();
        Class<?> c = type.getRawType();
        if (c.isEnum()) {
            schemasNode.set(typeName, schemaNode);
            schemaNode.put("id", typeName);
            schemaNode.put("type", "string");

            ArrayNode enumNode = objectMapper.createArrayNode();
            for (Object enumConstant : c.getEnumConstants()) {
                enumNode.add(enumConstant.toString());
            }
            schemaNode.set("enum", enumNode);
        } else {
            // JavaBean
            TypeToken<?> serializedType = ApiAnnotationIntrospector.getSchemaType(type, apiConfig);
            if (!type.equals(serializedType)) {
                return addTypeToSchema(schemasNode, serializedType, enclosingType, apiConfig, parameterConfigs);
            } else {
                addBeanTypeToSchema(schemasNode, typeName, schemaNode, type, apiConfig, parameterConfigs);
            }
        }
        return typeName;
    }

    private void addBeanTypeToSchema(ObjectNode schemasNode, String typeName, ObjectNode schemaNode,
            TypeToken<?> type, ApiConfig apiConfig, List<ApiParameterConfig> parameterConfigs)
            throws ApiConfigException {
        schemasNode.set(typeName, schemaNode);
        schemaNode.put("id", typeName);
        schemaNode.put("type", "object");
        ObjectNode propertiesNode = objectMapper.createObjectNode();
        addBeanProperties(schemasNode, propertiesNode, type, apiConfig, parameterConfigs);
        schemaNode.set("properties", propertiesNode);
    }

    /**
     * Iterates over the given JavaBean class and adds the following to the given config object
     * (the value of "properties" of a schema object): "&lt;name&gt;": {"type": "&lt;type&gt;"}, where
     * "name" is the name of the JavaBean property and "type" the type of its value.
     */
    private void addBeanProperties(ObjectNode schemasNode, ObjectNode node, TypeToken<?> beanType,
            ApiConfig apiConfig, List<ApiParameterConfig> parameterConfigs) throws ApiConfigException {
        // CollectionResponse<T> is treated as a bean but it is a parameterized type, too.
        ResourceSchema schema = resourceSchemaProvider.getResourceSchema(beanType, apiConfig);
        for (Entry<String, ResourcePropertySchema> entry : schema.getProperties().entrySet()) {
            String propertyName = entry.getKey();
            ObjectNode propertyNode = objectMapper.createObjectNode();
            TypeToken<?> propertyType = entry.getValue().getType();
            if (propertyType != null) {
                addTypeToNode(schemasNode, propertyType, beanType, propertyNode, apiConfig, parameterConfigs);
                node.set(propertyName, propertyNode);
            }
        }
    }

    /**
     * Adds a schema for a type into an output node. For arrays, this generates nested schemas inline
     * for however many dimensions are necessary.
     *
     * @return an appropriate name for the schema if one isn't already assigned
     */
    private String addTypeToNode(ObjectNode schemasNode, TypeToken<?> type, TypeToken<?> enclosingType,
            ObjectNode node, ApiConfig apiConfig, List<ApiParameterConfig> parameterConfigs)
            throws ApiConfigException {
        TypeToken<?> itemType = Types.getArrayItemType(type);

        if (typeLoader.isSchemaType(type)) {
            String basicTypeName = typeLoader.getSchemaType(type);
            addElementTypeToNode(schemasNode, type, basicTypeName, node, apiConfig);
            return basicTypeName;
        } else if (itemType != null) {
            ObjectNode items = objectMapper.createObjectNode();
            node.put("type", "array");
            node.set(Constant.ITEMS, items);

            String itemTypeName = addTypeToNode(schemasNode, itemType, enclosingType, items, apiConfig,
                    parameterConfigs);
            String arraySuffix = "Collection";
            StringBuilder sb = new StringBuilder(itemTypeName.length() + arraySuffix.length());
            sb.append(itemTypeName).append(arraySuffix);
            sb.setCharAt(0, Character.toUpperCase(sb.charAt(0)));
            return sb.toString();
        } else if (type instanceof TypeVariable) {
            throw new IllegalArgumentException(String.format("Object type %s not supported.", type));
        } else {
            String typeName = addTypeToSchema(schemasNode, type, enclosingType, apiConfig, parameterConfigs);
            addElementTypeToNode(schemasNode, type, typeName, node, apiConfig);
            return typeName;
        }
    }

    /**
     * Adds a basic (non-array) type to an output node, assuming the type has a corresponding schema
     * in the provided configuration if it will ever have one.
     */
    private void addElementTypeToNode(ObjectNode schemasNode, TypeToken<?> type, String typeName, ObjectNode node,
            ApiConfig apiConfig) {

        // This check works better than checking schemaTypes in the case of Map<K, V>
        if (schemasNode.has(typeName)) {
            node.put("$ref", typeName);
        } else {
            node.put("type", typeName);
            String format = schemaFormatForType(type, apiConfig);
            if (format != null) {
                node.put("format", format);
            }
        }
    }

    // If a type has a serializer installed, resolve down to target type of the serialization chain to
    // find the schema format.
    @Nullable
    private String schemaFormatForType(TypeToken<?> type, ApiConfig apiConfig) {
        TypeToken<?> serializedType = ApiAnnotationIntrospector.getSchemaType(type, apiConfig);
        if (!type.equals(serializedType)) {
            return schemaFormatForType(serializedType, apiConfig);
        }
        return typeLoader.getSchemaFormat(type);
    }
}