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
John Niang 2022-08-03 11:34:14 +08:00 committed by GitHub
parent 3302ce68c9
commit 86f9daf421
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 60 additions and 82 deletions

View File

@ -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'

View File

@ -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()

View File

@ -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) {

View File

@ -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);
}
/**

View File

@ -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;

View File

@ -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;
}
}

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.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) {

View File

@ -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());
}