mirror of https://github.com/halo-dev/halo
Replace JSON schema with OpenAPI 3.0 (#2300)
#### What type of PR is this? /kind improvement /area core /milestone 2.0 #### What this PR does / why we need it: This PR introduces [openapi4j](https://github.com/openapi4j/openapi4j) to replace JSON schema. See #2294 for more. #### Which issue(s) this PR fixes: Fix #2294 #### Does this PR introduce a user-facing change? ```release-note None ```pull/2321/head
parent
3302ce68c9
commit
86f9daf421
|
@ -46,8 +46,6 @@ bootJar {
|
|||
ext['h2.version'] = '2.1.210'
|
||||
ext {
|
||||
commonsLang3 = "3.12.0"
|
||||
jsonschemaGenerator = "4.24.3"
|
||||
jsonschemaValidator = "1.0.69"
|
||||
base62 = "0.1.3"
|
||||
pf4j = "3.6.0"
|
||||
}
|
||||
|
@ -66,16 +64,15 @@ dependencies {
|
|||
implementation 'org.springframework.security:spring-security-oauth2-resource-server'
|
||||
|
||||
implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.0.0-M3'
|
||||
implementation 'org.openapi4j:openapi-schema-validator:1.0.7'
|
||||
implementation "org.flywaydb:flyway-core"
|
||||
implementation "org.flywaydb:flyway-mysql"
|
||||
implementation "net.bytebuddy:byte-buddy"
|
||||
|
||||
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 "io.seruco.encoding:base62:$base62"
|
||||
implementation "org.pf4j:pf4j:$pf4j"
|
||||
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
testCompileOnly 'org.projectlombok:lombok'
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package run.halo.app.config;
|
|||
|
||||
import io.swagger.v3.oas.models.Components;
|
||||
import io.swagger.v3.oas.models.OpenAPI;
|
||||
import io.swagger.v3.oas.models.SpecVersion;
|
||||
import io.swagger.v3.oas.models.info.Info;
|
||||
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||
|
@ -13,8 +14,8 @@ import org.springframework.context.annotation.Configuration;
|
|||
public class SwaggerConfig {
|
||||
|
||||
@Bean
|
||||
OpenAPI customOpenAPI() {
|
||||
return new OpenAPI()
|
||||
OpenAPI haloOpenApi() {
|
||||
return new OpenAPI(SpecVersion.V30)
|
||||
// See https://swagger.io/docs/specification/authentication/ for more.
|
||||
.components(new Components()
|
||||
.addSecuritySchemes("BasicAuth", new SecurityScheme()
|
||||
|
|
|
@ -1,16 +1,12 @@
|
|||
package run.halo.app.extension;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.networknt.schema.JsonSchemaFactory;
|
||||
import com.networknt.schema.SpecVersion;
|
||||
import io.swagger.v3.core.util.Json;
|
||||
import java.io.IOException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.openapi4j.core.exception.ResolutionException;
|
||||
import org.openapi4j.schema.validator.ValidationData;
|
||||
import org.openapi4j.schema.validator.v3.SchemaValidator;
|
||||
import run.halo.app.extension.exception.ExtensionConvertException;
|
||||
import run.halo.app.extension.exception.SchemaViolationException;
|
||||
import run.halo.app.extension.store.ExtensionStore;
|
||||
|
@ -20,28 +16,20 @@ import run.halo.app.extension.store.ExtensionStore;
|
|||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class JSONExtensionConverter implements ExtensionConverter {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||
|
||||
public static final ObjectMapper OBJECT_MAPPER;
|
||||
private final JsonSchemaFactory jsonSchemaFactory;
|
||||
public final ObjectMapper objectMapper;
|
||||
|
||||
private final SchemeManager schemeManager;
|
||||
|
||||
|
||||
static {
|
||||
OBJECT_MAPPER = Jackson2ObjectMapperBuilder.json()
|
||||
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.featuresToDisable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS)
|
||||
.serializationInclusion(JsonInclude.Include.NON_NULL)
|
||||
.build();
|
||||
}
|
||||
|
||||
public JSONExtensionConverter(SchemeManager schemeManager) {
|
||||
this.schemeManager = schemeManager;
|
||||
jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909);
|
||||
this.objectMapper = Json.mapper();
|
||||
}
|
||||
|
||||
public ObjectMapper getObjectMapper() {
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -50,31 +38,33 @@ public class JSONExtensionConverter implements ExtensionConverter {
|
|||
var scheme = schemeManager.get(gvk);
|
||||
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
|
||||
try {
|
||||
var data = OBJECT_MAPPER.writeValueAsBytes(extension);
|
||||
var data = objectMapper.writeValueAsBytes(extension);
|
||||
|
||||
var validator = jsonSchemaFactory.getSchema(scheme.jsonSchema());
|
||||
var errors = validator.validate(OBJECT_MAPPER.readTree(data));
|
||||
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);
|
||||
var extensionJsonNode = objectMapper.valueToTree(extension);
|
||||
|
||||
var validator = new SchemaValidator(null, scheme.openApiSchema());
|
||||
var validation = new ValidationData<>(extension);
|
||||
validator.validate(extensionJsonNode, validation);
|
||||
if (!validation.isValid()) {
|
||||
log.debug("Failed to validate Extension: {}, and errors were: {}",
|
||||
extension.getClass(), validation.results());
|
||||
throw new SchemaViolationException("Failed to validate Extension "
|
||||
+ extension.getClass(), validation.results());
|
||||
}
|
||||
|
||||
var version = extension.getMetadata().getVersion();
|
||||
return new ExtensionStore(storeName, data, version);
|
||||
} catch (IOException e) {
|
||||
throw new ExtensionConvertException("Failed write Extension as bytes", e);
|
||||
} catch (ResolutionException e) {
|
||||
throw new RuntimeException("Failed to create schema validator", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public <E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore) {
|
||||
try {
|
||||
var extension = OBJECT_MAPPER.readValue(extensionStore.getData(), type);
|
||||
var extension = objectMapper.readValue(extensionStore.getData(), type);
|
||||
extension.getMetadata().setVersion(extensionStore.getVersion());
|
||||
return extension;
|
||||
} catch (IOException e) {
|
||||
|
|
|
@ -1,12 +1,9 @@
|
|||
package run.halo.app.extension;
|
||||
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.github.victools.jsonschema.generator.Option;
|
||||
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 io.swagger.v3.core.converter.ModelConverters;
|
||||
import io.swagger.v3.core.util.Json;
|
||||
import java.util.Map;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.extension.exception.ExtensionException;
|
||||
|
@ -18,20 +15,20 @@ import run.halo.app.extension.exception.ExtensionException;
|
|||
* @param groupVersionKind is GroupVersionKind of Extension.
|
||||
* @param plural is plural name of Extension.
|
||||
* @param singular is singular name of Extension.
|
||||
* @param jsonSchema is JSON schema of Extension.
|
||||
* @param openApiSchema is JSON schema of Extension.
|
||||
* @author johnniang
|
||||
*/
|
||||
public record Scheme(Class<? extends Extension> type,
|
||||
GroupVersionKind groupVersionKind,
|
||||
String plural,
|
||||
String singular,
|
||||
ObjectNode jsonSchema) {
|
||||
ObjectNode openApiSchema) {
|
||||
public Scheme {
|
||||
Assert.notNull(type, "Type 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(singular, "Singular name of Extension must not be blank");
|
||||
Assert.notNull(jsonSchema, "Json Schema must not be null");
|
||||
Assert.notNull(openApiSchema, "Json Schema must not be null");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -46,26 +43,19 @@ public record Scheme(Class<? extends Extension> type,
|
|||
var gvk = getGvkFromType(type);
|
||||
|
||||
// TODO Move the generation logic outside.
|
||||
// generate JSON schema
|
||||
var module = new Swagger2Module();
|
||||
var config =
|
||||
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON)
|
||||
.with(
|
||||
// See https://victools.github.io/jsonschema-generator/#generator-options
|
||||
// fore more.
|
||||
Option.INLINE_ALL_SCHEMAS,
|
||||
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES
|
||||
)
|
||||
.with(module)
|
||||
.build();
|
||||
var generator = new SchemaGenerator(config);
|
||||
var jsonSchema = generator.generateSchema(type);
|
||||
// generate OpenAPI schema
|
||||
var resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(type);
|
||||
var mapper = Json.mapper();
|
||||
var schema = (ObjectNode) mapper.valueToTree(resolvedSchema.schema);
|
||||
// for schema validation.
|
||||
schema.set("components",
|
||||
mapper.valueToTree(Map.of("schemas", resolvedSchema.referencedSchemas)));
|
||||
|
||||
return new Scheme(type,
|
||||
new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()),
|
||||
gvk.plural(),
|
||||
gvk.singular(),
|
||||
jsonSchema);
|
||||
schema);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -9,6 +9,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
|||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import io.swagger.v3.core.util.Json;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
|
@ -28,7 +29,7 @@ import java.util.Optional;
|
|||
@SuppressWarnings("rawtypes")
|
||||
public class Unstructured implements Extension {
|
||||
|
||||
public static final ObjectMapper OBJECT_MAPPER = JSONExtensionConverter.OBJECT_MAPPER;
|
||||
public static final ObjectMapper OBJECT_MAPPER = Json.mapper();
|
||||
|
||||
private final Map data;
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package run.halo.app.extension.exception;
|
||||
|
||||
import com.networknt.schema.ValidationMessage;
|
||||
import java.util.Set;
|
||||
import org.openapi4j.core.validation.ValidationResults;
|
||||
|
||||
/**
|
||||
* This exception is thrown when Schema is violation.
|
||||
|
@ -13,35 +12,34 @@ public class SchemaViolationException extends ExtensionException {
|
|||
/**
|
||||
* Validation errors.
|
||||
*/
|
||||
private final Set<ValidationMessage> errors;
|
||||
private final ValidationResults errors;
|
||||
|
||||
public SchemaViolationException(Set<ValidationMessage> errors) {
|
||||
public SchemaViolationException(ValidationResults errors) {
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, Set<ValidationMessage> errors) {
|
||||
public SchemaViolationException(String message, ValidationResults errors) {
|
||||
super(message);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, Throwable cause,
|
||||
Set<ValidationMessage> errors) {
|
||||
public SchemaViolationException(String message, Throwable cause, ValidationResults errors) {
|
||||
super(message, cause);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(Throwable cause, Set<ValidationMessage> errors) {
|
||||
public SchemaViolationException(Throwable cause, ValidationResults errors) {
|
||||
super(cause);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public SchemaViolationException(String message, Throwable cause, boolean enableSuppression,
|
||||
boolean writableStackTrace, Set<ValidationMessage> errors) {
|
||||
boolean writableStackTrace, ValidationResults errors) {
|
||||
super(message, cause, enableSuppression, writableStackTrace);
|
||||
this.errors = errors;
|
||||
}
|
||||
|
||||
public Set<ValidationMessage> getErrors() {
|
||||
public ValidationResults 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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
@ -29,7 +30,7 @@ class JsonExtensionConverterTest {
|
|||
|
||||
DefaultSchemeManager schemeManager = new DefaultSchemeManager(null);
|
||||
converter = new JSONExtensionConverter(schemeManager);
|
||||
objectMapper = JSONExtensionConverter.OBJECT_MAPPER;
|
||||
objectMapper = converter.getObjectMapper();
|
||||
|
||||
schemeManager.register(FakeExtension.class);
|
||||
}
|
||||
|
@ -83,8 +84,8 @@ class JsonExtensionConverterTest {
|
|||
fake.setKind("Fake");
|
||||
var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake));
|
||||
assertEquals(1, error.getErrors().size());
|
||||
assertEquals("$.metadata.name: is missing but it is required",
|
||||
error.getErrors().iterator().next().getMessage());
|
||||
// error.getErrors().items().get(0).message();
|
||||
assertTrue(error.getErrors().items().get(0).toString().contains("'name' is required"));
|
||||
}
|
||||
|
||||
FakeExtension createFakeExtension(String name, Long version) {
|
||||
|
|
|
@ -63,7 +63,7 @@ class SchemeTest {
|
|||
scheme.groupVersionKind());
|
||||
assertEquals("fake", scheme.singular());
|
||||
assertEquals("fakes", scheme.plural());
|
||||
assertNotNull(scheme.jsonSchema());
|
||||
assertNotNull(scheme.openApiSchema());
|
||||
assertEquals(FakeExtension.class, scheme.type());
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue