org.everit.json.schema.loader.SchemaLoader.java Source code

Java tutorial

Introduction

Here is the source code for org.everit.json.schema.loader.SchemaLoader.java

Source

/*
 * Copyright (C) 2011 Everit Kft. (http://www.everit.org)
 *
 * 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 org.everit.json.schema.loader;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.everit.json.schema.ArraySchema;
import org.everit.json.schema.BooleanSchema;
import org.everit.json.schema.CombinedSchema;
import org.everit.json.schema.EmptySchema;
import org.everit.json.schema.EnumSchema;
import org.everit.json.schema.NotSchema;
import org.everit.json.schema.NullSchema;
import org.everit.json.schema.NumberSchema;
import org.everit.json.schema.ObjectSchema;
import org.everit.json.schema.ObjectSchema.Builder;
import org.everit.json.schema.ReferenceSchema;
import org.everit.json.schema.Schema;
import org.everit.json.schema.SchemaException;
import org.everit.json.schema.StringSchema;
import org.everit.json.schema.loader.internal.DefaultSchemaClient;
import org.everit.json.schema.loader.internal.JSONPointer;
import org.everit.json.schema.loader.internal.JSONPointer.QueryResult;
import org.json.JSONArray;
import org.json.JSONObject;

/**
 * Loads a JSON schema's JSON representation into schema validator instances.
 */
public class SchemaLoader {

    /**
     * Created and used by {@link TypeBasedMultiplexer} to set actions (consumers) for matching
     * classes.
     *
     * @param <E>
     *          the type of the input to the operation.
     */
    @FunctionalInterface
    interface OnTypeConsumer<E> {
        TypeBasedMultiplexer then(Consumer<E> consumer);
    }

    /**
     * Used by {@code SchemaLoader} during schema loading for type-based action selections. In other
     * words this utility class is used for avoiding {@code if..instanceof..casting} constructs.
     * Together with the {@link OnTypeConsumer} implementations it forms a fluent API to deal with the
     * parts of the JSON schema where multiple kind of values are valid for a given key.
     *
     * <p>
     * Example usage: <code>
     * Object additProps = schemaJson.get("additionalProperties");
     * typeMultiplexer(additionalProps)
     * .ifIs(JSONArray.class).then(arr -> {...if additProps is a JSONArray then process it... })
     * .ifObject().then(obj -> {...if additProps is a JSONArray then process it... })
     * .requireAny(); // throw a SchemaException if additProps is neither a JSONArray nor a JSONObject
     * </code>
     * </p>
     */
    class TypeBasedMultiplexer {

        /**
         * An {@link OnTypeConsumer} implementation which wraps the action ({@code obj} consumer} set by
         * {@link #then(Consumer)} into an other consumer which maintains {@link SchemaLoader#id}.
         */
        private class IdModifyingTypeConsumerImpl extends OnTypeConsumerImpl<JSONObject> {

            IdModifyingTypeConsumerImpl(final Class<?> key) {
                super(key);
            }

            /**
             * Puts the {@code consumer} action with the {@code key} to the {@link TypeBasedMultiplexer}'s
             * action map, and wraps the consumer to an other consumer which properly maintains the
             * {@link SchemaLoader#id} attribute.
             *
             * @see {@link TypeBasedMultiplexer#ifObject()} for more details about the wrapping.
             */
            @Override
            public TypeBasedMultiplexer then(final Consumer<JSONObject> consumer) {
                Consumer<JSONObject> wrapperConsumer = obj -> {
                    String origId = id;
                    if (obj.has("id")) {
                        id += obj.getString("id");
                    }
                    consumer.accept(obj);
                    id = origId;
                };
                actions.put(key, wrapperConsumer);
                return TypeBasedMultiplexer.this;
            }

        }

        /**
         * Default implementation of {@link OnTypeConsumer}, instantiated by
         * {@link TypeBasedMultiplexer#ifIs(Class)}.
         *
         * @param <E>
         *          the type of the input to the operation.
         */
        private class OnTypeConsumerImpl<E> implements OnTypeConsumer<E> {

            protected final Class<?> key;

            OnTypeConsumerImpl(final Class<?> key) {
                this.key = key;
            }

            @Override
            public TypeBasedMultiplexer then(final Consumer<E> consumer) {
                actions.put(key, consumer);
                return TypeBasedMultiplexer.this;
            }

        }

        private final Map<Class<?>, Consumer<?>> actions = new HashMap<>();

        private final String keyOfObj;

        private final Object obj;

        /**
         * Constructor with {@code null} {@code keyOfObj}.
         */
        TypeBasedMultiplexer(final Object obj) {
            this(null, obj);
        }

        /**
         * Constructor.
         *
         * @param keyOfObj
         *          is an optional (nullable) string used by {@link #requireAny()} to construct the
         *          message of the {@link SchemaException} if no appropriate consumer action is found.
         * @param obj
         *          the object which' class is matched against the classes defined by
         *          {@link #ifIs(Class)} (or {@link #ifObject()}) calls.
         */
        TypeBasedMultiplexer(final String keyOfObj, final Object obj) {
            this.keyOfObj = keyOfObj;
            this.obj = obj;
        }

        /**
         * Creates a setter which will be invoked by {@link #orElse(Consumer)} or {@link #requireAny()}
         * if {@code obj} is an instance of {@code predicateClass}.
         *
         * @throws IllegalArgumentException
         *           if {@code predicateClass} is {@link JSONObject}. Use {@link #ifObject()} for
         *           matching {@code obj}'s class against {@link JSONObject}.
         */
        public <E> OnTypeConsumer<E> ifIs(final Class<E> predicateClass) {
            if (predicateClass == JSONObject.class) {
                throw new IllegalArgumentException("use ifObject() instead");
            }
            return new OnTypeConsumerImpl<E>(predicateClass);
        }

        /**
         * Creates a {@link JSONObject} consumer setter.
         *
         * <p>
         * The returned {@link OnTypeConsumer} implementation will wrap the
         * {@link OnTypeConsumer#then(Consumer) passed consumer action} with an other consumer which
         * properly maintains the {@link SchemaLoader#id} attribute, ie. if {@code obj} is a
         * {@link JSONObject} instance and it has an {@code id} property then it will append this id
         * value to {@link SchemaLoader#id} for the duration of the action execution, then it will
         * restore the original id.
         * </p>
         */
        public OnTypeConsumer<JSONObject> ifObject() {
            return new IdModifyingTypeConsumerImpl(JSONObject.class);
        }

        /**
         * Checks if the {@code obj} is an instance of any previously set classes (by
         * {@link #ifIs(Class)} or {@link #ifObject()}), performs the mapped action of found or invokes
         * {@code orElseConsumer} with the {@code obj}.
         */
        public void orElse(final Consumer<Object> orElseConsumer) {
            @SuppressWarnings("unchecked")
            Consumer<Object> consumer = (Consumer<Object>) actions.keySet().stream()
                    .filter(clazz -> clazz.isAssignableFrom(obj.getClass())).findFirst().map(actions::get)
                    .orElse(orElseConsumer::accept);
            consumer.accept(obj);

        }

        /**
         * Checks if the {@code obj} is an instance of any previously set classes (by
         * {@link #ifIs(Class)} or {@link #ifObject()}), performs the mapped action of found or throws
         * with a {@link SchemaException}.
         */
        public void requireAny() {
            orElse(obj -> {
                throw new SchemaException(keyOfObj, new ArrayList<Class<?>>(actions.keySet()), obj);
            });
        }
    }

    private static final List<String> ARRAY_SCHEMA_PROPS = Arrays.asList("items", "additionalItems", "minItems",
            "maxItems", "uniqueItems");

    private static final Map<String, Function<Collection<Schema>, CombinedSchema.Builder>> COMBINED_SUBSCHEMA_PROVIDERS = // CS_DISABLE_LINE_LENGTH
            new HashMap<>(3);

    private static final List<String> NUMBER_SCHEMA_PROPS = Arrays.asList("minimum", "maximum", "minimumExclusive",
            "maximumExclusive", "multipleOf");

    private static final List<String> OBJECT_SCHEMA_PROPS = Arrays.asList("properties", "required", "minProperties",
            "maxProperties", "dependencies", "patternProperties", "additionalProperties");

    private static final List<String> STRING_SCHEMA_PROPS = Arrays.asList("minLength", "maxLength", "pattern");

    static {
        COMBINED_SUBSCHEMA_PROVIDERS.put("allOf", CombinedSchema::allOf);
        COMBINED_SUBSCHEMA_PROVIDERS.put("anyOf", CombinedSchema::anyOf);
        COMBINED_SUBSCHEMA_PROVIDERS.put("oneOf", CombinedSchema::oneOf);
    }

    /**
     * Loads a JSON schema to a schema validator using a {@link DefaultSchemaClient default HTTP
     * client}.
     *
     * @param schemaJson
     *          the JSON representation of the schema.
     * @return the schema validator object
     */
    public static Schema load(final JSONObject schemaJson) {
        return SchemaLoader.load(schemaJson, new DefaultSchemaClient());
    }

    /**
     * Creates Schema instance from its JSON representation.
     *
     * @param schemaJson
     *          the JSON representation of the schema.
     * @param httpClient
     *          the HTTP client to be used for resolving remote JSON references.
     * @return the created schema
     */
    public static Schema load(final JSONObject schemaJson, final SchemaClient httpClient) {
        String schemaId = schemaJson.optString("id");
        return new SchemaLoader(schemaId, schemaJson, schemaJson, new HashMap<>(), httpClient).load().build();
    }

    private final SchemaClient httpClient;

    private String id = null;

    private final Map<String, ReferenceSchema.Builder> pointerSchemas;

    private final JSONObject rootSchemaJson;

    private final JSONObject schemaJson;

    /**
     * Constructor.
     */
    SchemaLoader(final String id, final JSONObject schemaJson, final JSONObject rootSchemaJson,
            final Map<String, ReferenceSchema.Builder> pointerSchemas, final SchemaClient httpClient) {
        this.schemaJson = Objects.requireNonNull(schemaJson, "schemaJson cannot be null");
        this.rootSchemaJson = Objects.requireNonNull(rootSchemaJson, "rootSchemaJson cannot be null");
        this.id = id;
        this.httpClient = Objects.requireNonNull(httpClient, "httpClient cannot be null");
        this.pointerSchemas = pointerSchemas;
    }

    private void addDependencies(final Builder builder, final JSONObject deps) {
        Arrays.stream(JSONObject.getNames(deps))
                .forEach(ifPresent -> addDependency(builder, ifPresent, deps.get(ifPresent)));
    }

    private void addDependency(final Builder builder, final String ifPresent, final Object deps) {
        typeMultiplexer(deps).ifObject().then(obj -> {
            builder.schemaDependency(ifPresent, loadChild(obj).build());
        }).ifIs(JSONArray.class).then(propNames -> {
            IntStream.range(0, propNames.length()).mapToObj(i -> propNames.getString(i))
                    .forEach(dependency -> builder.propertyDependency(ifPresent, dependency));
        }).requireAny();
    }

    private CombinedSchema.Builder buildAnyOfSchemaForMultipleTypes() {
        JSONArray subtypeJsons = schemaJson.getJSONArray("type");
        Map<String, Object> dummyJson = new HashMap<String, Object>();
        Collection<Schema> subschemas = new ArrayList<Schema>(subtypeJsons.length());
        for (int i = 0; i < subtypeJsons.length(); ++i) {
            Object subtypeJson = subtypeJsons.get(i);
            dummyJson.put("type", subtypeJson);
            JSONObject child = new JSONObject(dummyJson);
            subschemas.add(loadChild(child).build());
        }
        return CombinedSchema.anyOf(subschemas);
    }

    private ArraySchema.Builder buildArraySchema() {
        ArraySchema.Builder builder = ArraySchema.builder();
        ifPresent("minItems", Integer.class, builder::minItems);
        ifPresent("maxItems", Integer.class, builder::maxItems);
        ifPresent("uniqueItems", Boolean.class, builder::uniqueItems);
        if (schemaJson.has("additionalItems")) {
            typeMultiplexer("additionalItems", schemaJson.get("additionalItems")).ifIs(Boolean.class)
                    .then(builder::additionalItems).ifObject()
                    .then(jsonObj -> builder.schemaOfAdditionalItems(loadChild(jsonObj).build())).requireAny();
        }
        if (schemaJson.has("items")) {
            typeMultiplexer("items", schemaJson.get("items")).ifObject()
                    .then(itemSchema -> builder.allItemSchema(loadChild(itemSchema).build())).ifIs(JSONArray.class)
                    .then(arr -> buildTupleSchema(builder, arr)).requireAny();
        }
        return builder;
    }

    private EnumSchema.Builder buildEnumSchema() {
        Set<Object> possibleValues = new HashSet<>();
        JSONArray arr = schemaJson.getJSONArray("enum");
        IntStream.range(0, arr.length()).mapToObj(arr::get).forEach(possibleValues::add);
        return EnumSchema.builder().possibleValues(possibleValues);
    }

    private NotSchema.Builder buildNotSchema() {
        Schema mustNotMatch = loadChild(schemaJson.getJSONObject("not")).build();
        return NotSchema.builder().mustNotMatch(mustNotMatch);
    }

    private NumberSchema.Builder buildNumberSchema() {
        NumberSchema.Builder builder = NumberSchema.builder();
        ifPresent("minimum", Number.class, builder::minimum);
        ifPresent("maximum", Number.class, builder::maximum);
        ifPresent("multipleOf", Number.class, builder::multipleOf);
        ifPresent("exclusiveMinimum", Boolean.class, builder::exclusiveMinimum);
        ifPresent("exclusiveMaximum", Boolean.class, builder::exclusiveMaximum);
        return builder;
    }

    private ObjectSchema.Builder buildObjectSchema() {
        ObjectSchema.Builder builder = ObjectSchema.builder();
        ifPresent("minProperties", Integer.class, builder::minProperties);
        ifPresent("maxProperties", Integer.class, builder::maxProperties);
        if (schemaJson.has("properties")) {
            JSONObject propertyDefs = schemaJson.getJSONObject("properties");
            Arrays.stream(Optional.ofNullable(JSONObject.getNames(propertyDefs)).orElse(new String[0])).forEach(
                    key -> builder.addPropertySchema(key, loadChild(propertyDefs.getJSONObject(key)).build()));
        }
        if (schemaJson.has("additionalProperties")) {
            typeMultiplexer("additionalProperties", schemaJson.get("additionalProperties")).ifIs(Boolean.class)
                    .then(builder::additionalProperties).ifObject()
                    .then(def -> builder.schemaOfAdditionalProperties(loadChild(def).build())).requireAny();
        }
        if (schemaJson.has("required")) {
            JSONArray requiredJson = schemaJson.getJSONArray("required");
            IntStream.range(0, requiredJson.length()).mapToObj(requiredJson::getString)
                    .forEach(builder::addRequiredProperty);
        }
        if (schemaJson.has("patternProperties")) {
            JSONObject patternPropsJson = schemaJson.getJSONObject("patternProperties");
            String[] patterns = JSONObject.getNames(patternPropsJson);
            if (patterns != null) {
                for (String pattern : patterns) {
                    builder.patternProperty(pattern, loadChild(patternPropsJson.getJSONObject(pattern)).build());
                }
            }
        }
        ifPresent("dependencies", JSONObject.class, deps -> addDependencies(builder, deps));
        return builder;
    }

    private Schema.Builder<?> buildSchemaWithoutExplicitType() {
        if (schemaJson.length() == 0) {
            return EmptySchema.builder();
        }
        if (schemaJson.has("$ref")) {
            return lookupReference(schemaJson.getString("$ref"));
        }
        Schema.Builder<?> rval = sniffSchemaByProps();
        if (rval != null) {
            return rval;
        }
        if (schemaJson.has("not")) {
            return buildNotSchema();
        }
        return EmptySchema.builder();
    }

    private StringSchema.Builder buildStringSchema() {
        StringSchema.Builder builder = StringSchema.builder();
        ifPresent("minLength", Integer.class, builder::minLength);
        ifPresent("maxLength", Integer.class, builder::maxLength);
        ifPresent("pattern", String.class, builder::pattern);
        return builder;
    }

    private void buildTupleSchema(final ArraySchema.Builder builder, final JSONArray itemSchema) {
        for (int i = 0; i < itemSchema.length(); ++i) {
            typeMultiplexer(itemSchema.get(i)).ifObject()
                    .then(schema -> builder.addItemSchema(loadChild(schema).build())).requireAny();
        }
    }

    private <E> void ifPresent(final String key, final Class<E> expectedType, final Consumer<E> consumer) {
        if (schemaJson.has(key)) {
            @SuppressWarnings("unchecked")
            E value = (E) schemaJson.get(key);
            try {
                consumer.accept(value);
            } catch (ClassCastException e) {
                throw new SchemaException(key, expectedType, value);
            }
        }
    }

    /**
     * Populates a {@code Schema.Builder} instance from the {@code schemaJson} schema definition.
     *
     * @return the builder which already contains the validation criteria of the schema, therefore
     *         {@link Schema.Builder#build()} can be immediately used to acquire the {@link Schema}
     *         instance to be used for validation
     */
    private Schema.Builder<?> load() {
        Schema.Builder<?> builder;
        if (schemaJson.has("enum")) {
            builder = buildEnumSchema();
        } else {
            builder = tryCombinedSchema();
            if (builder == null) {
                if (!schemaJson.has("type")) {
                    builder = buildSchemaWithoutExplicitType();
                } else {
                    builder = loadForType(schemaJson.get("type"));
                }
            }
        }
        ifPresent("id", String.class, builder::id);
        ifPresent("title", String.class, builder::title);
        ifPresent("description", String.class, builder::description);
        return builder;
    }

    private Schema.Builder<?> loadChild(final JSONObject childJson) {
        return new SchemaLoader(id, childJson, rootSchemaJson, pointerSchemas, httpClient).load();
    }

    private Schema.Builder<?> loadForExplicitType(final String typeString) {
        switch (typeString) {
        case "string":
            return buildStringSchema();
        case "integer":
            return buildNumberSchema().requiresInteger(true);
        case "number":
            return buildNumberSchema();
        case "boolean":
            return BooleanSchema.builder();
        case "null":
            return NullSchema.builder();
        case "array":
            return buildArraySchema();
        case "object":
            return buildObjectSchema();
        default:
            throw new SchemaException(String.format("unknown type: [%s]", typeString));
        }
    }

    private Schema.Builder<?> loadForType(final Object type) {
        if (type instanceof JSONArray) {
            return buildAnyOfSchemaForMultipleTypes();
        } else if (type instanceof String) {
            return loadForExplicitType((String) type);
        } else {
            throw new SchemaException("type", Arrays.asList(JSONArray.class, String.class), type);
        }
    }

    /**
     * Returns a schema builder instance after looking up the JSON pointer.
     */
    private Schema.Builder<?> lookupReference(final String relPointerString) {
        String absPointerString = id + relPointerString;
        if (pointerSchemas.containsKey(absPointerString)) {
            return pointerSchemas.get(absPointerString);
        }
        JSONPointer pointer;
        if (absPointerString.startsWith("#")) {
            pointer = JSONPointer.forDocument(rootSchemaJson, absPointerString);
        } else {
            pointer = JSONPointer.forURL(httpClient, absPointerString);
        }
        ReferenceSchema.Builder refBuilder = ReferenceSchema.builder();
        pointerSchemas.put(absPointerString, refBuilder);
        QueryResult result = pointer.query();
        SchemaLoader childLoader = new SchemaLoader(id, result.getQueryResult(), result.getContainingDocument(),
                pointerSchemas, httpClient);
        Schema referredSchema = childLoader.load().build();
        refBuilder.build().setReferredSchema(referredSchema);
        return refBuilder;
    }

    private boolean schemaHasAnyOf(final Collection<String> propNames) {
        return propNames.stream().filter(schemaJson::has).findAny().isPresent();
    }

    private Schema.Builder<?> sniffSchemaByProps() {
        if (schemaHasAnyOf(ARRAY_SCHEMA_PROPS)) {
            return buildArraySchema().requiresArray(false);
        } else if (schemaHasAnyOf(OBJECT_SCHEMA_PROPS)) {
            return buildObjectSchema().requiresObject(false);
        } else if (schemaHasAnyOf(NUMBER_SCHEMA_PROPS)) {
            return buildNumberSchema().requiresNumber(false);
        } else if (schemaHasAnyOf(STRING_SCHEMA_PROPS)) {
            return buildStringSchema().requiresString(false);
        }
        return null;
    }

    private CombinedSchema.Builder tryCombinedSchema() {
        List<String> presentKeys = COMBINED_SUBSCHEMA_PROVIDERS.keySet().stream().filter(schemaJson::has)
                .collect(Collectors.toList());
        if (presentKeys.size() > 1) {
            throw new SchemaException(
                    String.format("expected at most 1 of 'allOf', 'anyOf', 'oneOf', %d found", presentKeys.size()));
        } else if (presentKeys.size() == 1) {
            String key = presentKeys.get(0);
            JSONArray subschemaDefs = schemaJson.getJSONArray(key);
            Collection<Schema> subschemas = IntStream.range(0, subschemaDefs.length())
                    .mapToObj(subschemaDefs::getJSONObject).map(this::loadChild).map(Schema.Builder::build)
                    .collect(Collectors.toList());
            CombinedSchema.Builder combinedSchema = COMBINED_SUBSCHEMA_PROVIDERS.get(key).apply(subschemas);
            Schema.Builder<?> baseSchema;
            if (schemaJson.has("type")) {
                baseSchema = loadForType(schemaJson.get("type"));
            } else {
                baseSchema = sniffSchemaByProps();
            }
            if (baseSchema == null) {
                return combinedSchema;
            } else {
                return CombinedSchema.allOf(Arrays.asList(baseSchema.build(), combinedSchema.build()));
            }
        } else {
            return null;
        }
    }

    TypeBasedMultiplexer typeMultiplexer(final Object obj) {
        return new TypeBasedMultiplexer(obj);
    }

    TypeBasedMultiplexer typeMultiplexer(final String keyOfObj, final Object obj) {
        return new TypeBasedMultiplexer(keyOfObj, obj);
    }
}