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['h2.version'] = '2.1.210'
ext { ext {
commonsLang3 = "3.12.0" commonsLang3 = "3.12.0"
jsonschemaGenerator = "4.24.3"
jsonschemaValidator = "1.0.69"
} }
dependencies { dependencies {
@ -60,6 +62,9 @@ dependencies {
implementation "org.flywaydb:flyway-core" implementation "org.flywaydb:flyway-core"
implementation "org.flywaydb:flyway-mysql" 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" implementation "org.apache.commons:commons-lang3:$commonsLang3"
compileOnly 'org.projectlombok:lombok' 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import java.io.IOException; import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStore;
/** /**
@ -15,10 +21,14 @@ import run.halo.app.extension.store.ExtensionStore;
@Component @Component
public class JSONExtensionConverter implements ExtensionConverter { public class JSONExtensionConverter implements ExtensionConverter {
private final Logger logger = LoggerFactory.getLogger(getClass());
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final JsonSchemaFactory jsonSchemaFactory;
public JSONExtensionConverter(ObjectMapper objectMapper) { public JSONExtensionConverter(ObjectMapper objectMapper) {
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
} }
@Override @Override
@ -26,9 +36,26 @@ public class JSONExtensionConverter implements ExtensionConverter {
var scheme = Schemes.INSTANCE.get(extension.getClass()); var scheme = Schemes.INSTANCE.get(extension.getClass());
var storeName = ExtensionUtil.buildStoreName(scheme, extension.metadata().getName()); var storeName = ExtensionUtil.buildStoreName(scheme, extension.metadata().getName());
try { 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 // keep converting
var data = objectMapper.writeValueAsBytes(extension); var data = objectMapper.writeValueAsBytes(extensionNode);
var version = extension.metadata().getVersion(); var version = extension.metadata().getVersion();
return new ExtensionStore(storeName, data, version); return new ExtensionStore(storeName, data, version);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {

View File

@ -1,5 +1,6 @@
package run.halo.app.extension; package run.halo.app.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant; import java.time.Instant;
import java.util.Map; import java.util.Map;
import lombok.Data; import lombok.Data;
@ -15,31 +16,37 @@ public class Metadata {
/** /**
* Metadata name. The name is unique globally. * Metadata name. The name is unique globally.
*/ */
@Schema(required = true)
private String name; private String name;
/** /**
* Labels are like key-value format. * Labels are like key-value format.
*/ */
@Schema(nullable = true)
private Map<String, String> labels; private Map<String, String> labels;
/** /**
* Annotations are like key-value format. * Annotations are like key-value format.
*/ */
@Schema(nullable = true)
private Map<String, String> annotations; private Map<String, String> annotations;
/** /**
* Current version of the Extension. It will be bumped up every update. * Current version of the Extension. It will be bumped up every update.
*/ */
@Schema(nullable = true)
private Long version; private Long version;
/** /**
* Creation timestamp of the Extension. * Creation timestamp of the Extension.
*/ */
@Schema(nullable = true)
private Instant creationTimestamp; private Instant creationTimestamp;
/** /**
* Deletion timestamp of the Extension. * Deletion timestamp of the Extension.
*/ */
@Schema(nullable = true)
private Instant deletionTimestamp; 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.notNull(groupVersionKind, "GroupVersionKind of Extension must not be null");
Assert.hasText(plural, "Plural name of Extension must not be blank"); Assert.hasText(plural, "Plural name of Extension must not be blank");
Assert.hasText(singular, "Singular name of Extension must not be blank"); Assert.hasText(singular, "Singular name of Extension must not be blank");
Assert.notNull(jsonSchema, "Json Schema must not be null");
//TODO Validate the json schema when we plan to integrate Extension validation.
// Assert.notNull(jsonSchema, "Json Schema must not be null");
} }
} }

View File

@ -1,5 +1,10 @@
package run.halo.app.extension; 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.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.Map; import java.util.Map;
@ -69,9 +74,17 @@ public enum Schemes {
type.getName())); 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()), 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); 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 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.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@ -17,12 +18,12 @@ class ExtensionUtilTest {
new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"),
"fakes", "fakes",
"fake", "fake",
null); new ObjectNode(null));
grouplessScheme = new Scheme(FakeExtension.class, grouplessScheme = new Scheme(FakeExtension.class,
new GroupVersionKind("", "v1alpha1", "Fake"), new GroupVersionKind("", "v1alpha1", "Fake"),
"fakes", "fakes",
"fake", "fake",
null); new ObjectNode(null));
} }
@Test @Test

View File

@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStore;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@ -57,7 +58,7 @@ class JSONExtensionConverterTest {
} }
@Test @Test
void shouldThrowExceptionWhenDataIsInvalid() { void shouldThrowConvertExceptionWhenDataIsInvalid() {
var store = new ExtensionStore(); var store = new ExtensionStore();
store.setName("/registry/fake.halo.run/fakes/fake"); store.setName("/registry/fake.halo.run/fakes/fake");
store.setVersion(20L); store.setVersion(20L);
@ -67,6 +68,18 @@ class JSONExtensionConverterTest {
() -> converter.convertFrom(FakeExtension.class, store)); () -> 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) { FakeExtension createFakeExtension(String name, Long version) {
var fake = new FakeExtension(); var fake = new FakeExtension();

View File

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