Add JSON schema generator and validator (#2093)

Only validate the JSON schema when saving Extension into database.
pull/2102/head
John Niang 2022-05-18 00:06:12 +08:00 committed by GitHub
parent 264f9e39cb
commit 5f9daf4735
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 125 additions and 13 deletions

View File

@ -44,6 +44,8 @@ bootJar {
ext['h2.version'] = '2.1.210'
ext {
commonsLang3 = "3.12.0"
jsonschemaGenerator = "4.24.3"
jsonschemaValidator = "1.0.69"
}
dependencies {
@ -60,6 +62,9 @@ dependencies {
implementation "org.flywaydb:flyway-core"
implementation "org.flywaydb:flyway-mysql"
implementation "com.github.victools:jsonschema-generator:$jsonschemaGenerator"
implementation "com.github.victools:jsonschema-module-swagger-2:$jsonschemaGenerator"
implementation "com.networknt:json-schema-validator:$jsonschemaValidator"
implementation "org.apache.commons:commons-lang3:$commonsLang3"
compileOnly 'org.projectlombok:lombok'

View File

@ -2,9 +2,15 @@ package run.halo.app.extension;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore;
/**
@ -15,10 +21,14 @@ import run.halo.app.extension.store.ExtensionStore;
@Component
public class JSONExtensionConverter implements ExtensionConverter {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final ObjectMapper objectMapper;
private final JsonSchemaFactory jsonSchemaFactory;
public JSONExtensionConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
}
@Override
@ -26,9 +36,26 @@ public class JSONExtensionConverter implements ExtensionConverter {
var scheme = Schemes.INSTANCE.get(extension.getClass());
var storeName = ExtensionUtil.buildStoreName(scheme, extension.metadata().getName());
try {
// TODO Validate the extension in ExtensionClient
if (logger.isDebugEnabled()) {
logger.debug("JSON schema({}): {}", scheme.type(),
scheme.jsonSchema().toPrettyString());
}
var validator = jsonSchemaFactory.getSchema(scheme.jsonSchema());
var extensionNode = objectMapper.valueToTree(extension);
var errors = validator.validate(extensionNode);
if (!CollectionUtils.isEmpty(errors)) {
if (logger.isDebugEnabled()) {
// only print the errors when debug mode is enabled
logger.error("Failed to validate Extension {}, and errors are: {}",
extension.getClass(), errors);
}
throw new SchemaViolationException(
"Failed to validate Extension " + extension.getClass(), errors);
}
// keep converting
var data = objectMapper.writeValueAsBytes(extension);
var data = objectMapper.writeValueAsBytes(extensionNode);
var version = extension.metadata().getVersion();
return new ExtensionStore(storeName, data, version);
} catch (JsonProcessingException e) {

View File

@ -1,5 +1,6 @@
package run.halo.app.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
import lombok.Data;
@ -15,31 +16,37 @@ public class Metadata {
/**
* Metadata name. The name is unique globally.
*/
@Schema(required = true)
private String name;
/**
* Labels are like key-value format.
*/
@Schema(nullable = true)
private Map<String, String> labels;
/**
* Annotations are like key-value format.
*/
@Schema(nullable = true)
private Map<String, String> annotations;
/**
* Current version of the Extension. It will be bumped up every update.
*/
@Schema(nullable = true)
private Long version;
/**
* Creation timestamp of the Extension.
*/
@Schema(nullable = true)
private Instant creationTimestamp;
/**
* Deletion timestamp of the Extension.
*/
@Schema(nullable = true)
private Instant deletionTimestamp;
}

View File

@ -23,9 +23,7 @@ public record Scheme(Class<? extends Extension> type,
Assert.notNull(groupVersionKind, "GroupVersionKind of Extension must not be null");
Assert.hasText(plural, "Plural name of Extension must not be blank");
Assert.hasText(singular, "Singular name of Extension must not be blank");
//TODO Validate the json schema when we plan to integrate Extension validation.
// Assert.notNull(jsonSchema, "Json Schema must not be null");
Assert.notNull(jsonSchema, "Json Schema must not be null");
}
}

View File

@ -1,5 +1,10 @@
package run.halo.app.extension;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
@ -69,9 +74,17 @@ public enum Schemes {
type.getName()));
}
// TODO Generate the JSON schema here
// generate JSON schema
var module = new Swagger2Module();
var config =
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
.with(module)
.build();
var generator = new SchemaGenerator(config);
var jsonSchema = generator.generateSchema(type);
var scheme = new Scheme(type, new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()),
gvk.plural(), gvk.singular(), null);
gvk.plural(), gvk.singular(), jsonSchema);
register(scheme);
}

View File

@ -0,0 +1,47 @@
package run.halo.app.extension.exception;
import com.networknt.schema.ValidationMessage;
import java.util.Set;
/**
* This exception is thrown when Schema is violation.
*
* @author johnniang
*/
public class SchemaViolationException extends ExtensionException {
/**
* Validation errors.
*/
private final Set<ValidationMessage> errors;
public SchemaViolationException(Set<ValidationMessage> errors) {
this.errors = errors;
}
public SchemaViolationException(String message, Set<ValidationMessage> errors) {
super(message);
this.errors = errors;
}
public SchemaViolationException(String message, Throwable cause,
Set<ValidationMessage> errors) {
super(message, cause);
this.errors = errors;
}
public SchemaViolationException(Throwable cause, Set<ValidationMessage> errors) {
super(cause);
this.errors = errors;
}
public SchemaViolationException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace, Set<ValidationMessage> errors) {
super(message, cause, enableSuppression, writableStackTrace);
this.errors = errors;
}
public Set<ValidationMessage> getErrors() {
return errors;
}
}

View File

@ -2,6 +2,7 @@ package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.databind.node.ObjectNode;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -17,12 +18,12 @@ class ExtensionUtilTest {
new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"),
"fakes",
"fake",
null);
new ObjectNode(null));
grouplessScheme = new Scheme(FakeExtension.class,
new GroupVersionKind("", "v1alpha1", "Fake"),
"fakes",
"fake",
null);
new ObjectNode(null));
}
@Test

View File

@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore;
@ExtendWith(MockitoExtension.class)
@ -57,7 +58,7 @@ class JSONExtensionConverterTest {
}
@Test
void shouldThrowExceptionWhenDataIsInvalid() {
void shouldThrowConvertExceptionWhenDataIsInvalid() {
var store = new ExtensionStore();
store.setName("/registry/fake.halo.run/fakes/fake");
store.setVersion(20L);
@ -67,6 +68,18 @@ class JSONExtensionConverterTest {
() -> converter.convertFrom(FakeExtension.class, store));
}
@Test
void shouldThrowSchemaViolationExceptionWhenNameNotSet() {
var fake = new FakeExtension();
Metadata metadata = new Metadata();
fake.setMetadata(metadata);
fake.setApiVersion("fake.halo.run/v1alpha1");
fake.setKind("Fake");
var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake));
assertEquals(1, error.getErrors().size());
assertEquals("$.metadata.name: null found, string expected",
error.getErrors().iterator().next().getMessage());
}
FakeExtension createFakeExtension(String name, Long version) {
var fake = new FakeExtension();

View File

@ -23,9 +23,10 @@ class SchemeTest {
assertThrows(IllegalArgumentException.class,
() -> new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"),
"fakes", "", null));
new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes",
"fake", null);
assertThrows(IllegalArgumentException.class, () -> {
new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes",
"fake", null);
});
new Scheme(FakeExtension.class, new GroupVersionKind("", "v1alpha1", "Fake"), "fakes",
"fake", new ObjectNode(null));
}