Skip to content

Commit

Permalink
JSON Schema Validation
Browse files Browse the repository at this point in the history
remove properties file
  • Loading branch information
sdelamo committed Apr 10, 2024
1 parent b014c4f commit 3ddaa13
Show file tree
Hide file tree
Showing 42 changed files with 1,076 additions and 191 deletions.
4 changes: 2 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ micronaut-test = "4.2.1"
micronaut-validation = "4.5.0"

groovy = "4.0.17"
json-schema-validator = "1.4.0"
managed-json-schema-validator = "1.4.0"
kotlin = "1.9.23"
ksp = "1.9.23-1.0.19"
spock = "2.3-groovy-4.0"
Expand All @@ -36,7 +36,7 @@ micronaut-serde = { module = "io.micronaut.serde:micronaut-serde-bom", version.r
micronaut-test = { module = "io.micronaut.test:micronaut-test-bom", version.ref = "micronaut-test" }
micronaut-validation = { module = "io.micronaut.validation:micronaut-validation-bom", version.ref = "micronaut-validation" }

json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "json-schema-validator" }
managed-json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "managed-json-schema-validator" }

junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api" }
junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,11 @@ public record JsonSchemaContext(
) {
public static final String DEFAULT_OUTPUT_LOCATION = "schemas";
public static final boolean DEFAULT_BINARY_AS_ARRAY = false;
private static final String DEFAULT_BASE_URL = "http://localhost:8080/schemas";

public static JsonSchemaContext createDefault() {
return new JsonSchemaContext(
DEFAULT_OUTPUT_LOCATION, null, DEFAULT_BINARY_AS_ARRAY, new HashMap<>()
DEFAULT_OUTPUT_LOCATION, DEFAULT_BASE_URL, DEFAULT_BINARY_AS_ARRAY, new HashMap<>()
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ class JacksonJsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.properties == null
schema.oneOf.size() == 2

schema.oneOf[0].$ref == '/salamander.schema.json'
schema.oneOf[1].$ref == '/alligator.schema.json'
schema.oneOf[0].$ref == 'http://localhost:8080/schemas/salamander.schema.json'
schema.oneOf[1].$ref == 'http://localhost:8080/schemas/alligator.schema.json'
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class ReferenceSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.title == "Possum"
schema.properties.size() == 1
schema.properties['children'].type == [Schema.Type.ARRAY]
schema.properties['children'].items.$ref == '/possum.schema.json'
schema.properties['children'].items.$ref == 'http://localhost:8080/schemas/possum.schema.json'
}

void "schema reference"() {
Expand Down Expand Up @@ -58,7 +58,7 @@ class ReferenceSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.title == "Player"
schema.properties.size() == 2
schema.properties['name'].type == [Schema.Type.STRING]
schema.properties['pos'].$ref == '/position.schema.json'
schema.properties['pos'].$ref == 'http://localhost:8080/schemas/position.schema.json'
}

}
24 changes: 24 additions & 0 deletions json-schema-validation/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
plugins {
id("io.micronaut.build.internal.json-schema-module")
}
dependencies {
api(mn.micronaut.json.core)
api(projects.micronautJsonSchemaAnnotations)
api(libs.managed.json.schema.validator)

// JSON Schema
testAnnotationProcessor(projects.micronautJsonSchemaProcessor)

// Validation
testAnnotationProcessor(mnValidation.micronaut.validation.processor)
testImplementation(mnValidation.micronaut.validation)

// Serialization
testImplementation(mn.micronaut.jackson.databind)

testAnnotationProcessor(mn.micronaut.inject.java)
testImplementation(libs.junit.jupiter.api)
testImplementation(mnTest.micronaut.test.junit5)
testRuntimeOnly(libs.junit.jupiter.engine)
testImplementation(libs.junit.jupiter.params)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
* Copyright 2017-2024 original authors
*
* 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
*
* https://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.micronaut.jsonschema.validation;

import com.networknt.schema.*;
import com.networknt.schema.resource.SchemaMappers;
import io.micronaut.core.annotation.AnnotationValue;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.annotation.NonNull;
import io.micronaut.core.beans.BeanIntrospection;
import io.micronaut.core.beans.exceptions.IntrospectionException;
import io.micronaut.core.io.ResourceLoader;
import io.micronaut.core.naming.NameUtils;
import io.micronaut.json.JsonMapper;
import jakarta.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.stream.Collectors;

@Singleton
@Internal
final class DefaultJsonSchemaValidator implements JsonSchemaValidator {
private static final Logger LOG = LoggerFactory.getLogger(DefaultJsonSchemaValidator.class);
private static final String CLASSPATH_PREFIX = "classpath:META-INF/schemas/";
private static final String SUFFIX = ".schema.json";
private static final String MEMBER_URI = "uri";
private static final ExecutionContextCustomizer CONTEXT_CUSTOMIZER = (executionContext, validationContext) -> {
// By default since Draft 2019-09 the format keyword only generates annotations and not assertions
validationContext.getConfig().setFormatAssertionsEnabled(true);
};

private final Map<Class<?>, JsonSchema> jsonSchemaCache = new ConcurrentHashMap<>();
private final ResourceLoader resourceLoader;
private final JsonMapper jsonMapper;
private final SchemaValidatorsConfig schemaValidatorsConfig;

DefaultJsonSchemaValidator(ResourceLoader resourceLoader,
JsonMapper jsonMapper,
SchemaValidatorsConfig schemaValidatorsConfig) {
this.resourceLoader = resourceLoader;
this.jsonMapper = jsonMapper;
this.schemaValidatorsConfig = schemaValidatorsConfig;
}

@Override
public <T> Set<? extends ValidationMessage> validate(@NonNull String json, @NonNull Class<T> type, List<Function<String, String>> mappings) {
JsonSchema schema = jsonSchemaCache.computeIfAbsent(type, t -> jsonSchemaForClass(t, mappings));
return validate(schema, json);
}

@Override
@NonNull
public <T> Set<? extends ValidationMessage> validate(@NonNull Object value, @NonNull Class<T> type, List<Function<String, String>> mappings) throws IOException {
JsonSchema schema = jsonSchemaCache.computeIfAbsent(type, t -> jsonSchemaForClass(t, mappings));
String json = jsonMapper.writeValueAsString(value);
return validate(schema, json);
}

private <T> JsonSchema jsonSchemaForClass(@NonNull Class<T> type, List<Function<String, String>> mappings) {
String jsonSchema = jsonSchemaStringForClass(type);
if (jsonSchema == null) {
throw new IllegalArgumentException("No schema found for type: " + type);
}
JsonSchemaFactory jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012, builder -> {
try {
builder.schemaMappers(schemaMappersBuilderCustomizer(mappings));
} catch (MalformedURLException e) {
LOG.warn("{}", e);
}
});
return jsonSchemaFactory.getSchema(jsonSchema, schemaValidatorsConfig);
}

private <T> String jsonSchemaStringForClass(@NonNull Class<T> type) {
String path = jsonSchemaPath(type);
try (InputStream inputStream = resourceLoader.getResourceAsStream(path).orElseThrow(() -> new IllegalArgumentException("No schema found for type: " + type + " at path: " + path))) {
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
return null;
}
}

private static <T> String jsonSchemaPath(@NonNull Class<T> type) {
String className = NameUtils.hyphenate(type.getSimpleName());
try {
BeanIntrospection<T> introspection = BeanIntrospection.getIntrospection(type);
AnnotationValue<io.micronaut.jsonschema.JsonSchema> jsonSchemaAnnotationValue = introspection.getAnnotation(io.micronaut.jsonschema.JsonSchema.class);
Optional<String> uriOptional = jsonSchemaAnnotationValue.stringValue(MEMBER_URI);
if (uriOptional.isPresent()) {
className = uriOptional.get().replace("/", "");
}
} catch (IntrospectionException e) {
LOG.debug("Introspection exception for class {}.}", type, e);
}
String name = className + SUFFIX;
return CLASSPATH_PREFIX + name;
}

private static Consumer<SchemaMappers.Builder> schemaMappersBuilderCustomizer(List<Function<String, String>> mappings) throws MalformedURLException {
return builder -> mappings.forEach(builder::mappings);
}

private static Set<? extends ValidationMessage> validate(JsonSchema schema, String json) {
return schema.validate(json, InputFormat.JSON, CONTEXT_CUSTOMIZER)
.stream()
.map(ValidationMessageAdapter::new)
.collect(Collectors.toSet());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2017-2024 original authors
*
* 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
*
* https://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.micronaut.jsonschema.validation;

import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.PathType;
import com.networknt.schema.SchemaValidatorsConfig;
import io.micronaut.context.annotation.Bean;
import io.micronaut.context.annotation.Factory;
import io.micronaut.core.annotation.Internal;

/**
* {@link Factory} to instantiate {@link SchemaValidatorsConfig}, {@link JsonSchemaFactory} beans related to JSON Schema validation.
* @author Sergio del Amo
* @since 1.0.0
*/
@Internal
@Factory
class JsonSchemaValidationFactory {
@Bean
SchemaValidatorsConfig jsonSchemaValidator() {
var config = new SchemaValidatorsConfig();
config.setPathType(PathType.JSON_POINTER);
return config;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2017-2024 original authors
*
* 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
*
* https://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.micronaut.jsonschema.validation;

import io.micronaut.context.annotation.DefaultImplementation;
import io.micronaut.core.annotation.NonNull;

import java.io.IOException;
import java.util.List;
import java.util.Set;
import java.util.function.Function;

/**
* JSON Schema Validator.
* @author Sergio del Amo
* @since 1.0.0
*/
@DefaultImplementation(DefaultJsonSchemaValidator.class)
public interface JsonSchemaValidator {

/**
*
* @param value JSON value to validate
* @param type The type used to generate the JSON Schema
* @return A set of validation messages. Empty if valid.
* @param <T> Type used to generate the JSON Schema
* @throws IOException If an error occurs validating the JSON against the schema.
*/
@NonNull
default <T> Set<? extends ValidationMessage> validate(@NonNull String value, @NonNull Class<T> type) throws IOException {
return validate(value, type, List.of(s -> s.replace("http://localhost:8080/schemas", "classpath:META-INF/schemas")));
}



/**
*
* @param value JSON value to validate
* @param type The type used to generate the JSON Schema
* @param mappings Additional mappings to be used when generating the JSON Schema
* @return A set of validation messages. Empty if valid.
* @param <T> Type used to generate the JSON Schema
* @throws IOException If an error occurs validating the JSON against the schema.
*/
@NonNull
<T> Set<? extends ValidationMessage> validate(@NonNull String value, @NonNull Class<T> type, List<Function<String, String>> mappings) throws IOException;

/**
*
* @param value Object to validate against a JSON schema
* @param type The type used to generate the JSON Schema
* @return A set of validation messages. Empty if valid.
* @param <T> Type used to generate the JSON Schema
* @throws IOException If an error occurs validating the JSON against the schema.
*/
@NonNull
default <T> Set<? extends ValidationMessage> validate(@NonNull Object value, @NonNull Class<T> type) throws IOException {
return validate(value, type, List.of(s -> s.replace("http://localhost:8080/schemas", "classpath:META-INF/schemas")));
}

/**
*
* @param value Object to validate against a JSON schema
* @param type The type used to generate the JSON Schema
* @param mappings Additional mappings to be used when generating the JSON Schema
* @return A set of validation messages. Empty if valid.
* @param <T> Type used to generate the JSON Schema
* @throws IOException If an error occurs validating the JSON against the schema.
*/
@NonNull
<T> Set<? extends ValidationMessage> validate(@NonNull Object value, @NonNull Class<T> type, List<Function<String, String>> mappings) throws IOException;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2017-2024 original authors
*
* 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
*
* https://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.micronaut.jsonschema.validation;

/**
* JSON Schema Validation Message.
* @author Sergio del Amo
* @since 1.0.0
*/
@FunctionalInterface
public interface ValidationMessage {

/**
*
* @return JSON Schema Validation Message
*/
String getMessage();
}
Loading

0 comments on commit 3ddaa13

Please sign in to comment.