All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.streamnative.pulsar.handlers.kop.schemaregistry.providers.json.JsonSchema Maven / Gradle / Ivy

The newest version!
/**
 * Copyright (c) 2019 - 2024 StreamNative, 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 io.streamnative.pulsar.handlers.kop.schemaregistry.providers.json;

import com.fasterxml.jackson.core.JsonProcessingException;
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.BinaryNode;
import com.fasterxml.jackson.databind.node.BooleanNode;
import com.fasterxml.jackson.databind.node.NullNode;
import com.fasterxml.jackson.databind.node.NumericNode;
import com.fasterxml.jackson.databind.node.TextNode;
import io.apicurio.registry.rules.compatibility.JsonSchemaCompatibilityDifference;
import io.apicurio.registry.rules.compatibility.jsonschema.JsonSchemaDiffLibrary;
import io.apicurio.registry.rules.compatibility.jsonschema.diff.Difference;
import io.streamnative.pulsar.handlers.kop.schemaregistry.model.ParsedSchema;
import io.streamnative.pulsar.handlers.kop.schemaregistry.model.rest.SchemaReference;
import io.streamnative.pulsar.handlers.kop.schemaregistry.providers.json.jackson.Jackson;
import java.io.IOException;
import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;
import org.everit.json.schema.Schema;
import org.everit.json.schema.ValidationException;
import org.everit.json.schema.loader.SchemaLoader;
import org.everit.json.schema.loader.SpecificationVersion;
import org.everit.json.schema.loader.internal.ReferenceResolver;
import org.json.JSONArray;
import org.json.JSONObject;

@Slf4j
public class JsonSchema implements ParsedSchema {

    public static final String TYPE = "JSON";

    private static final String SCHEMA_KEYWORD = "$schema";

    private static final Object NONE_MARKER = new Object();

    private final JsonNode jsonNode;

    private transient Schema schemaObj;

    private final Integer version;

    private final List references;

    private final Map resolvedReferences;

    private transient String canonicalString;

    private transient int hashCode = NO_HASHCODE;

    private static final int NO_HASHCODE = Integer.MIN_VALUE;

    private static final ObjectMapper objectMapper = Jackson.newObjectMapper();
    private static final ObjectMapper objectMapperWithOrderedProps = Jackson.newObjectMapper(true);

    public JsonSchema(String schemaString) {
        this(schemaString, Collections.emptyList(), Collections.emptyMap(), null);
    }

    public JsonSchema(Schema schemaObj) {
        this(schemaObj, null);
    }

    public JsonSchema(Schema schemaObj, Integer version) {
        try {
            this.jsonNode = schemaObj != null ? objectMapper.readTree(schemaObj.toString()) : null;
            this.schemaObj = schemaObj;
            this.version = version;
            this.references = Collections.emptyList();
            this.resolvedReferences = Collections.emptyMap();
        } catch (IOException e) {
            throw new IllegalArgumentException("Invalid JSON " + schemaObj, e);
        }
    }

    public JsonSchema(
        String schemaString,
        List references,
        Map resolvedReferences,
        Integer version
    ) {
        try {
            this.jsonNode = schemaString != null ? objectMapper.readTree(schemaString) : null;
            this.version = version;
            this.references = Collections.unmodifiableList(references);
            this.resolvedReferences = Collections.unmodifiableMap(resolvedReferences);
        } catch (IOException e) {
            throw new IllegalArgumentException("Invalid JSON " + schemaString, e);
        }
    }

    public JsonSchema(
        JsonNode jsonNode,
        List references,
        Map resolvedReferences,
        Integer version
    ) {
        this.jsonNode = jsonNode;
        this.version = version;
        this.references = Collections.unmodifiableList(references);
        this.resolvedReferences = Collections.unmodifiableMap(resolvedReferences);
    }

    private JsonSchema(
        JsonNode jsonNode,
        Schema schemaObj,
        Integer version,
        List references,
        Map resolvedReferences,
        String canonicalString
    ) {
        this.jsonNode = jsonNode;
        this.schemaObj = schemaObj;
        this.version = version;
        this.references = references;
        this.resolvedReferences = resolvedReferences;
        this.canonicalString = canonicalString;
    }

    @Override
    public JsonSchema copy() {
        return new JsonSchema(
            this.jsonNode,
            this.schemaObj,
            this.version,
            this.references,
            this.resolvedReferences,
            this.canonicalString
        );
    }

    @Override
    public JsonSchema copy(Integer version) {
        return new JsonSchema(
            this.jsonNode,
            this.schemaObj,
            version,
            this.references,
            this.resolvedReferences,
            this.canonicalString
        );
    }

    public JsonNode toJsonNode() {
        return jsonNode;
    }

    @Override
    public Schema rawSchema() {
        if (jsonNode == null) {
            return null;
        }
        if (schemaObj == null) {
            try {
                // Extract the $schema to use for determining the id keyword
                SpecificationVersion spec = SpecificationVersion.DRAFT_7;
                if (jsonNode.has(SCHEMA_KEYWORD)) {
                    String schema = jsonNode.get(SCHEMA_KEYWORD).asText();
                    if (schema != null) {
                        spec = SpecificationVersion.lookupByMetaSchemaUrl(schema)
                            .orElse(SpecificationVersion.DRAFT_7);
                    }
                }
                // Extract the $id to use for resolving relative $ref URIs
                URI idUri = null;
                if (jsonNode.has(spec.idKeyword())) {
                    String id = jsonNode.get(spec.idKeyword()).asText();
                    if (id != null) {
                        idUri = ReferenceResolver.resolve((URI) null, id);
                    }
                }
                SchemaLoader.SchemaLoaderBuilder builder = SchemaLoader.builder()
                    .useDefaults(true).draftV7Support();
                for (Map.Entry dep : resolvedReferences.entrySet()) {
                    URI child = ReferenceResolver.resolve(idUri, dep.getKey());
                    builder.registerSchemaByURI(child, new JSONObject(dep.getValue()));
                }
                JSONObject jsonObject = objectMapper.treeToValue(jsonNode, JSONObject.class);
                builder.schemaJson(jsonObject);
                SchemaLoader loader = builder.build();
                schemaObj = loader.load().build();
            } catch (IOException e) {
                throw new IllegalArgumentException("Invalid JSON", e);
            }
        }
        return schemaObj;
    }

    @Override
    public String schemaType() {
        return TYPE;
    }

    @Override
    public String name() {
        return getString("title");
    }

    public String getString(String key) {
        return jsonNode.has(key) ? jsonNode.get(key).asText() : null;
    }

    @Override
    public String canonicalString() {
        if (jsonNode == null) {
            return null;
        }
        if (canonicalString == null) {
            try {
                canonicalString = objectMapper.writeValueAsString(jsonNode);
            } catch (IOException e) {
                throw new IllegalArgumentException("Invalid JSON", e);
            }
        }
        return canonicalString;
    }

    @Override
    public Integer version() {
        return version;
    }

    @Override
    public List references() {
        return references;
    }

    public Map resolvedReferences() {
        return resolvedReferences;
    }

    @Override
    public JsonSchema normalize() {
        String canonical = canonicalString();
        if (canonical == null) {
            return this;
        }
        try {
            JsonNode jsonNode = objectMapperWithOrderedProps.readTree(canonical);
            return new JsonSchema(
                jsonNode,
                this.references.stream().sorted().distinct().collect(Collectors.toList()),
                this.resolvedReferences,
                this.version
            );
        } catch (IOException e) {
            throw new IllegalArgumentException("Invalid JSON", e);
        }
    }

    @Override
    public void validate() {
        // Access the raw schema since it is computed lazily
        rawSchema();
    }

    public void validate(Object value) throws JsonProcessingException, ValidationException {
        validate(rawSchema(), value);
    }

    public static void validate(Schema schema, Object value)
        throws JsonProcessingException, ValidationException {
        Object primitiveValue = NONE_MARKER;
        if (isPrimitive(value)) {
            primitiveValue = value;
        } else if (value instanceof BinaryNode) {
            primitiveValue = ((BinaryNode) value).asText();
        } else if (value instanceof BooleanNode) {
            primitiveValue = ((BooleanNode) value).asBoolean();
        } else if (value instanceof NullNode) {
            primitiveValue = null;
        } else if (value instanceof NumericNode) {
            primitiveValue = ((NumericNode) value).numberValue();
        } else if (value instanceof TextNode) {
            primitiveValue = ((TextNode) value).asText();
        }
        if (primitiveValue != NONE_MARKER) {
            schema.validate(primitiveValue);
        } else {
            Object jsonObject;
            if (value instanceof ArrayNode) {
                jsonObject = objectMapper.treeToValue(((ArrayNode) value), JSONArray.class);
            } else if (value instanceof JsonNode) {
                jsonObject = objectMapper.treeToValue(((JsonNode) value), JSONObject.class);
            } else if (value.getClass().isArray()) {
                jsonObject = objectMapper.convertValue(value, JSONArray.class);
            } else {
                jsonObject = objectMapper.convertValue(value, JSONObject.class);
            }
            schema.validate(jsonObject);
        }
    }

    private static boolean isPrimitive(Object value) {
        return value == null
            || value instanceof Boolean
            || value instanceof Number
            || value instanceof String;
    }

    @Override
    public List isBackwardCompatible(ParsedSchema previousSchema) {
        if (!schemaType().equals(previousSchema.schemaType())) {
            return Collections.singletonList("Incompatible because of different schema type");
        }
        Set incompatibleDifferences = JsonSchemaDiffLibrary.getIncompatibleDifferences(
            ((JsonSchema) previousSchema).rawSchema().toString(), rawSchema().toString(), Collections.emptyMap());

        boolean isCompatible = incompatibleDifferences.isEmpty();
        if (!isCompatible) {
            List errorMessages = new ArrayList<>();
            for (Difference incompatibleDiff : incompatibleDifferences) {
                JsonSchemaCompatibilityDifference diff = JsonSchemaCompatibilityDifference
                    .builder()
                    .difference(incompatibleDiff)
                    .build();
                String errMsg = diff.asRuleViolation().getDescription() + " at " + diff.asRuleViolation().getContext();
                errorMessages.add(errMsg);
            }
            return errorMessages;
        }
        return Collections.emptyList();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        JsonSchema that = (JsonSchema) o;
        return Objects.equals(version, that.version)
            && Objects.equals(references, that.references)
            && Objects.equals(canonicalString(), that.canonicalString());
    }

    @Override
    public int hashCode() {
        if (hashCode == NO_HASHCODE) {
            hashCode = Objects.hash(jsonNode, references, version);
        }
        return hashCode;
    }

    @Override
    public String toString() {
        return canonicalString();
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy