mirror of https://github.com/halo-dev/halo
Add JSON schema generator and validator (#2093)
Only validate the JSON schema when saving Extension into database.pull/2102/head
parent
264f9e39cb
commit
5f9daf4735
|
@ -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'
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue