JsonSchemaInspector.java

/*
 * Copyright (C) 2017 Red Hat, Inc.
 *
 * 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 io.atlasmap.json.inspect;

import java.net.URI;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

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

import io.atlasmap.json.core.JsonComplexTypeFactory;
import io.atlasmap.json.v2.AtlasJsonModelFactory;
import io.atlasmap.json.v2.JsonComplexType;
import io.atlasmap.json.v2.JsonDocument;
import io.atlasmap.json.v2.JsonEnumField;
import io.atlasmap.json.v2.JsonEnumFields;
import io.atlasmap.json.v2.JsonField;
import io.atlasmap.json.v2.JsonFields;
import io.atlasmap.v2.CollectionType;
import io.atlasmap.v2.FieldStatus;
import io.atlasmap.v2.FieldType;
import io.atlasmap.v2.Json;

/**
 */
public class JsonSchemaInspector implements JsonInspector {

    private static final Logger LOG = LoggerFactory.getLogger(JsonSchemaInspector.class);
    private static JsonSchemaInspector myself = new JsonSchemaInspector();

    private JsonSchemaInspector() {
    }

    public static JsonSchemaInspector instance() {
        return myself;
    }

    public JsonDocument inspect(String schema) throws JsonInspectionException {
        if (schema == null || schema.isEmpty()) {
            throw new IllegalArgumentException("JSON schema cannot be null");
        }

        try {
            JsonDocument jsonDocument = AtlasJsonModelFactory.createJsonDocument();
            ObjectMapper objectMapper = new ObjectMapper()
                .enable(MapperFeature.BLOCK_UNSAFE_POLYMORPHIC_BASE_TYPES);
            JsonNode rootNode = objectMapper.readTree(schema);

            Map<String, JsonNode> definitionMap = new HashMap<>();
            populateDefinitions(rootNode, definitionMap);
            JsonField rootNodeType = getJsonFieldBuilder("", rootNode, null, definitionMap, new HashSet<>(), false).build();

            if (rootNodeType instanceof JsonComplexType
                    && ((JsonComplexType)rootNodeType).getJsonFields().getJsonField().size() != 0) {
                if (rootNodeType.getCollectionType() == null || rootNodeType.getCollectionType() == CollectionType.NONE) {
                    jsonDocument.getFields().getField().addAll(((JsonComplexType)rootNodeType).getJsonFields().getJsonField());
                } else {
                    // taking care of topmost collection
                    jsonDocument.getFields().getField().add(rootNodeType);
                }
            } else if (rootNodeType.getFieldType() == FieldType.COMPLEX) {
                LOG.warn("No simple type nor property is defined for the root node. It's going to be empty");
            } else {
                jsonDocument.getFields().getField().add(rootNodeType);
            }

            return jsonDocument;
        } catch (Exception e) {
            throw new JsonInspectionException(e);
        }
    }

    /**
     * Store the JsonNode rather than pre-built JsonComplexType as path needs to be filled by their own.
     */
    private void populateDefinitions(JsonNode node, Map<String, JsonNode> definitionMap) {
        JsonNode definitions = node.get("definitions");
        if (definitions == null) {
            return;
        }

        definitions.fields().forEachRemaining((entry) -> {
            String name = entry.getKey();
            JsonNode def = entry.getValue();
            JsonNode id = def.get("$id");
            if (id != null && !id.asText().isEmpty()) {
                definitionMap.put(id.asText(), def);
            }
            definitionMap.put("#/definitions/" + name, def);
        });
    }

    private List<JsonField> loadProperties(JsonNode node, String parentPath, Map<String, JsonNode> definitionMap, Set<String> definitionTrace) throws JsonInspectionException {
        List<JsonField> answer = new ArrayList<>();
        JsonNode properties = node.get("properties");
        if (properties == null || !properties.fields().hasNext()) {
            LOG.warn("An object node without 'properties', it will be ignored: {}", node);
            return answer;
        }

        Iterator<Entry<String, JsonNode>> topFields = properties.fields();
        while (topFields.hasNext()) {
            Entry<String, JsonNode> entry = topFields.next();
            if (!entry.getValue().isObject()) {
                LOG.warn("Ignoring non-object field '{}'", entry);
                continue;
            }
            JsonField type = getJsonFieldBuilder(entry.getKey(), entry.getValue(), parentPath, definitionMap, definitionTrace, false).build();
            answer.add(type);
        }
        return answer;
    }

    private JsonFieldBuilder getJsonFieldBuilder(String name, JsonNode value, String parentPath,
            Map<String, JsonNode> definitionMap, Set<String> definitionTrace, boolean isArray) throws JsonInspectionException {
        LOG.trace("--> Field:[name=[{}], value=[{}], parentPath=[{}]", name, value, parentPath);
        JsonFieldBuilder builder = new JsonFieldBuilder();
        if (name != null) {
            builder.name = name;
            builder.path = (parentPath != null && !parentPath.equals("/")
                    ? parentPath.concat("/") : "/").concat(name);
        }
        if (isArray) {
            builder.path += "<>";
            builder.collectionType = CollectionType.LIST;
        }
        builder.status = FieldStatus.SUPPORTED;

        JsonNode nodeValue = value;
        populateDefinitions(nodeValue, definitionMap);
        if (isRecursive(nodeValue, definitionTrace)) {
            builder.type = FieldType.COMPLEX;
            builder.status = FieldStatus.CACHED;
            return builder;
        } else {
            definitionTrace = new HashSet<>(definitionTrace);
            nodeValue = resolveReference(nodeValue, definitionMap, definitionTrace);
        }

        JsonNode fieldEnum = nodeValue.get("enum");
        if (fieldEnum != null) {
            builder.type = FieldType.COMPLEX;
            if (fieldEnum.isArray()) {
                final JsonFieldBuilder finalBuilder = builder;
                ((ArrayNode)fieldEnum).forEach(item -> {
                    JsonEnumField itemField = new JsonEnumField();
                    itemField.setName(item.isNull() ? null : item.asText());
                    finalBuilder.enumFields.getJsonEnumField().add(itemField);
                });
            } else if (!fieldEnum.isEmpty()) {
                JsonEnumField itemField = new JsonEnumField();
                itemField.setName(fieldEnum.isNull() ? null : fieldEnum.asText());
                builder.enumFields.getJsonEnumField().add(itemField);
            }
            return builder;
        }

        JsonNode fieldType = nodeValue.get("type");
        if (fieldType == null || fieldType.asText() == null) {
            LOG.warn("'type' is not defined for node '{}', assuming as an object", name);
            builder.type = FieldType.COMPLEX;
            builder.subFields.getJsonField().addAll(loadProperties(nodeValue, builder.path, definitionMap, definitionTrace));
            return builder;
        } else if ("array".equals(fieldType.asText())) {
            JsonNode arrayItems = nodeValue.get("items");
            if (arrayItems == null || !arrayItems.fields().hasNext()) {
                LOG.warn("'{}' is an array node, but no 'items' found in it. It will be ignored", name);
                builder.status = FieldStatus.UNSUPPORTED;
            } else {
                builder = getJsonFieldBuilder(name, arrayItems, parentPath, definitionMap, definitionTrace,true);
            }
            return builder;
        }
        List<String> jsonTypes = new LinkedList<>();
        if (fieldType instanceof ArrayNode) {
            ((ArrayNode)fieldType).spliterator().forEachRemaining(node -> jsonTypes.add(node.asText()));
        } else {
            jsonTypes.add(fieldType.asText());
        }
        processFieldType(builder, jsonTypes, nodeValue, definitionMap, definitionTrace);
        
        return builder;
    }

    private void processFieldType(JsonFieldBuilder builder, List<String> jsonTypes,
            JsonNode nodeValue, Map<String, JsonNode> definitionMap, Set<String> definitionTrace) throws JsonInspectionException {
        String jsonType = jsonTypes.get(0);
        if (jsonTypes.size() > 1) {
            if (jsonTypes.contains("object")) {
                jsonType = "object";
            } else if (jsonTypes.contains("string")) {
                jsonType = "string";
            } else if (jsonTypes.contains("number")) {
                jsonType = "number";
            } else if (jsonTypes.contains("integer")) {
                jsonType = "integer";
            } else if (jsonTypes.contains("boolean")) {
                jsonType = "boolean";
            } else {
                jsonType = "null";
            }
        }
        if ("boolean".equals(jsonType)) {
            builder.type = FieldType.BOOLEAN;
        } else if ("integer".equals(jsonType)) {
            builder.type = FieldType.BIG_INTEGER;
        } else if ("null".equals(jsonType)) {
            builder.type = FieldType.NONE;
        } else if ("number".equals(jsonType)) {
            builder.type = FieldType.NUMBER;
        } else if ("string".equals(jsonType)) {
            builder.type = FieldType.STRING;
        } else {
            if (!"object".equals(jsonType)) {
                LOG.warn("Unsupported field type '{}' found, assuming as an object", jsonType);
            }
            builder.type = FieldType.COMPLEX;
            builder.subFields.getJsonField().addAll(loadProperties(nodeValue, builder.path, definitionMap, definitionTrace));
        }
    }

    private class JsonFieldBuilder {
        private String name;
        private String path;
        private FieldType type;
        private CollectionType collectionType;
        private FieldStatus status;
        private JsonFields subFields = new JsonFields();
        private JsonEnumFields enumFields = new JsonEnumFields();

        public JsonField build() {
            JsonField answer;
            if (type == FieldType.COMPLEX) {
                JsonComplexType complex = JsonComplexTypeFactory.createJsonComlexField();
                complex.setJsonFields(subFields);
                complex.setJsonEnumFields(enumFields);
                if (!enumFields.getJsonEnumField().isEmpty()) {
                    complex.setEnumeration(true);
                }
                answer = complex;
            } else {
                answer = new JsonField();
                answer.setFieldType(type);
            }
            answer.setName(name);
            answer.setPath(path);
            answer.setCollectionType(collectionType);
            answer.setStatus(status);
            return answer;
        }
    }

    private boolean isRecursive(JsonNode node, Set<String> definitionTrace) {
        if (node.get("$ref") == null) {
            return false;
        }
        String uri = node.get("$ref").asText();
        if (uri == null || uri.isEmpty()) {
            return false;
        }

        return definitionTrace.contains(uri);
    }

    private JsonNode resolveReference(JsonNode node, Map<String, JsonNode> definitionMap, Set<String> definitionTrace) {
        if (node.get("$ref") == null) {
            return node;
        }
        String uri = node.get("$ref").asText();
        if (uri == null || uri.isEmpty()) {
            return node;
        }

        LOG.trace("Resolving JSON schema reference '{}'", uri);
        // internal reference precedes even if it's full URL
        JsonNode def = definitionMap.get(uri);
        if (def != null) {
            definitionTrace.add(uri);
            return def;
        }

        // then try external resource
        try {
            JsonNode external = Json.mapper().readTree(new URI(uri).toURL().openStream());
            LOG.trace("Successfully fetched external JSON schema '{}'    ", uri);
            definitionMap.put(uri, external);
            return external;
        } catch (Exception e) {
            LOG.debug("", e);
            LOG.warn("The referenced schema '{}' is not found. Ignoring", node.get("$ref"));
            return node;
        }
    }

}