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['h2.version'] = '2.1.210'
ext { ext {
commonsLang3 = "3.12.0" commonsLang3 = "3.12.0"
jsonschemaGenerator = "4.24.3"
jsonschemaValidator = "1.0.69"
base62 = "0.1.3" base62 = "0.1.3"
pf4j = "3.6.0" pf4j = "3.6.0"
} }
@ -66,16 +64,15 @@ dependencies {
implementation 'org.springframework.security:spring-security-oauth2-resource-server' implementation 'org.springframework.security:spring-security-oauth2-resource-server'
implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.0.0-M3' 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-core"
implementation "org.flywaydb:flyway-mysql" implementation "org.flywaydb:flyway-mysql"
implementation "net.bytebuddy:byte-buddy" 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 "org.apache.commons:commons-lang3:$commonsLang3"
implementation "io.seruco.encoding:base62:$base62" implementation "io.seruco.encoding:base62:$base62"
implementation "org.pf4j:pf4j:$pf4j" implementation "org.pf4j:pf4j:$pf4j"
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
testCompileOnly '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.Components;
import io.swagger.v3.oas.models.OpenAPI; 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.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.security.SecurityScheme;
@ -13,8 +14,8 @@ import org.springframework.context.annotation.Configuration;
public class SwaggerConfig { public class SwaggerConfig {
@Bean @Bean
OpenAPI customOpenAPI() { OpenAPI haloOpenApi() {
return new OpenAPI() return new OpenAPI(SpecVersion.V30)
// See https://swagger.io/docs/specification/authentication/ for more. // See https://swagger.io/docs/specification/authentication/ for more.
.components(new Components() .components(new Components()
.addSecuritySchemes("BasicAuth", new SecurityScheme() .addSecuritySchemes("BasicAuth", new SecurityScheme()

View File

@ -1,16 +1,12 @@
package run.halo.app.extension; package run.halo.app.extension;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import io.swagger.v3.core.util.Json;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SpecVersion;
import java.io.IOException; import java.io.IOException;
import org.slf4j.Logger; import lombok.extern.slf4j.Slf4j;
import org.slf4j.LoggerFactory; import org.openapi4j.core.exception.ResolutionException;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.openapi4j.schema.validator.ValidationData;
import org.springframework.stereotype.Component; import org.openapi4j.schema.validator.v3.SchemaValidator;
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.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore; import run.halo.app.extension.store.ExtensionStore;
@ -20,28 +16,20 @@ import run.halo.app.extension.store.ExtensionStore;
* *
* @author johnniang * @author johnniang
*/ */
@Component @Slf4j
public class JSONExtensionConverter implements ExtensionConverter { public class JSONExtensionConverter implements ExtensionConverter {
private final Logger logger = LoggerFactory.getLogger(getClass()); public final ObjectMapper objectMapper;
public static final ObjectMapper OBJECT_MAPPER;
private final JsonSchemaFactory jsonSchemaFactory;
private final SchemeManager schemeManager; 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) { public JSONExtensionConverter(SchemeManager schemeManager) {
this.schemeManager = schemeManager; this.schemeManager = schemeManager;
jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V201909); this.objectMapper = Json.mapper();
}
public ObjectMapper getObjectMapper() {
return objectMapper;
} }
@Override @Override
@ -50,31 +38,33 @@ public class JSONExtensionConverter implements ExtensionConverter {
var scheme = schemeManager.get(gvk); var scheme = schemeManager.get(gvk);
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName()); var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
try { try {
var data = OBJECT_MAPPER.writeValueAsBytes(extension); var data = objectMapper.writeValueAsBytes(extension);
var validator = jsonSchemaFactory.getSchema(scheme.jsonSchema()); var extensionJsonNode = objectMapper.valueToTree(extension);
var errors = validator.validate(OBJECT_MAPPER.readTree(data));
if (!CollectionUtils.isEmpty(errors)) { var validator = new SchemaValidator(null, scheme.openApiSchema());
if (logger.isDebugEnabled()) { var validation = new ValidationData<>(extension);
// only print the errors when debug mode is enabled validator.validate(extensionJsonNode, validation);
logger.error("Failed to validate Extension {}, and errors are: {}", if (!validation.isValid()) {
extension.getClass(), errors); log.debug("Failed to validate Extension: {}, and errors were: {}",
} extension.getClass(), validation.results());
throw new SchemaViolationException( throw new SchemaViolationException("Failed to validate Extension "
"Failed to validate Extension " + extension.getClass(), errors); + extension.getClass(), validation.results());
} }
var version = extension.getMetadata().getVersion(); var version = extension.getMetadata().getVersion();
return new ExtensionStore(storeName, data, version); return new ExtensionStore(storeName, data, version);
} catch (IOException e) { } catch (IOException e) {
throw new ExtensionConvertException("Failed write Extension as bytes", e); throw new ExtensionConvertException("Failed write Extension as bytes", e);
} catch (ResolutionException e) {
throw new RuntimeException("Failed to create schema validator", e);
} }
} }
@Override @Override
public <E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore) { public <E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore) {
try { try {
var extension = OBJECT_MAPPER.readValue(extensionStore.getData(), type); var extension = objectMapper.readValue(extensionStore.getData(), type);
extension.getMetadata().setVersion(extensionStore.getVersion()); extension.getMetadata().setVersion(extensionStore.getVersion());
return extension; return extension;
} catch (IOException e) { } catch (IOException e) {

View File

@ -1,12 +1,9 @@
package run.halo.app.extension; package run.halo.app.extension;
import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.Option; import io.swagger.v3.core.converter.ModelConverters;
import com.github.victools.jsonschema.generator.OptionPreset; import io.swagger.v3.core.util.Json;
import com.github.victools.jsonschema.generator.SchemaGenerator; import java.util.Map;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaVersion;
import com.github.victools.jsonschema.module.swagger2.Swagger2Module;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import run.halo.app.extension.exception.ExtensionException; 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 groupVersionKind is GroupVersionKind of Extension.
* @param plural is plural name of Extension. * @param plural is plural name of Extension.
* @param singular is singular 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 * @author johnniang
*/ */
public record Scheme(Class<? extends Extension> type, public record Scheme(Class<? extends Extension> type,
GroupVersionKind groupVersionKind, GroupVersionKind groupVersionKind,
String plural, String plural,
String singular, String singular,
ObjectNode jsonSchema) { ObjectNode openApiSchema) {
public Scheme { public Scheme {
Assert.notNull(type, "Type of Extension must not be null"); Assert.notNull(type, "Type of Extension must not be null");
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"); Assert.notNull(openApiSchema, "Json Schema must not be null");
} }
/** /**
@ -46,26 +43,19 @@ public record Scheme(Class<? extends Extension> type,
var gvk = getGvkFromType(type); var gvk = getGvkFromType(type);
// TODO Move the generation logic outside. // TODO Move the generation logic outside.
// generate JSON schema // generate OpenAPI schema
var module = new Swagger2Module(); var resolvedSchema = ModelConverters.getInstance().readAllAsResolvedSchema(type);
var config = var mapper = Json.mapper();
new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON) var schema = (ObjectNode) mapper.valueToTree(resolvedSchema.schema);
.with( // for schema validation.
// See https://victools.github.io/jsonschema-generator/#generator-options schema.set("components",
// fore more. mapper.valueToTree(Map.of("schemas", resolvedSchema.referencedSchemas)));
Option.INLINE_ALL_SCHEMAS,
Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES
)
.with(module)
.build();
var generator = new SchemaGenerator(config);
var jsonSchema = generator.generateSchema(type);
return new Scheme(type, return new Scheme(type,
new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()), new GroupVersionKind(gvk.group(), gvk.version(), gvk.kind()),
gvk.plural(), gvk.plural(),
gvk.singular(), 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.SerializerProvider;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import io.swagger.v3.core.util.Json;
import java.io.IOException; import java.io.IOException;
import java.time.Instant; import java.time.Instant;
import java.util.Arrays; import java.util.Arrays;
@ -28,7 +29,7 @@ import java.util.Optional;
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
public class Unstructured implements Extension { 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; private final Map data;

View File

@ -1,7 +1,6 @@
package run.halo.app.extension.exception; package run.halo.app.extension.exception;
import com.networknt.schema.ValidationMessage; import org.openapi4j.core.validation.ValidationResults;
import java.util.Set;
/** /**
* This exception is thrown when Schema is violation. * This exception is thrown when Schema is violation.
@ -13,35 +12,34 @@ public class SchemaViolationException extends ExtensionException {
/** /**
* Validation errors. * Validation errors.
*/ */
private final Set<ValidationMessage> errors; private final ValidationResults errors;
public SchemaViolationException(Set<ValidationMessage> errors) { public SchemaViolationException(ValidationResults errors) {
this.errors = errors; this.errors = errors;
} }
public SchemaViolationException(String message, Set<ValidationMessage> errors) { public SchemaViolationException(String message, ValidationResults errors) {
super(message); super(message);
this.errors = errors; this.errors = errors;
} }
public SchemaViolationException(String message, Throwable cause, public SchemaViolationException(String message, Throwable cause, ValidationResults errors) {
Set<ValidationMessage> errors) {
super(message, cause); super(message, cause);
this.errors = errors; this.errors = errors;
} }
public SchemaViolationException(Throwable cause, Set<ValidationMessage> errors) { public SchemaViolationException(Throwable cause, ValidationResults errors) {
super(cause); super(cause);
this.errors = errors; this.errors = errors;
} }
public SchemaViolationException(String message, Throwable cause, boolean enableSuppression, public SchemaViolationException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace, Set<ValidationMessage> errors) { boolean writableStackTrace, ValidationResults errors) {
super(message, cause, enableSuppression, writableStackTrace); super(message, cause, enableSuppression, writableStackTrace);
this.errors = errors; this.errors = errors;
} }
public Set<ValidationMessage> getErrors() { public ValidationResults getErrors() {
return errors; 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 static org.junit.jupiter.api.Assertions.assertThrows; 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.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -29,7 +30,7 @@ class JsonExtensionConverterTest {
DefaultSchemeManager schemeManager = new DefaultSchemeManager(null); DefaultSchemeManager schemeManager = new DefaultSchemeManager(null);
converter = new JSONExtensionConverter(schemeManager); converter = new JSONExtensionConverter(schemeManager);
objectMapper = JSONExtensionConverter.OBJECT_MAPPER; objectMapper = converter.getObjectMapper();
schemeManager.register(FakeExtension.class); schemeManager.register(FakeExtension.class);
} }
@ -83,8 +84,8 @@ class JsonExtensionConverterTest {
fake.setKind("Fake"); fake.setKind("Fake");
var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake)); var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake));
assertEquals(1, error.getErrors().size()); assertEquals(1, error.getErrors().size());
assertEquals("$.metadata.name: is missing but it is required", // error.getErrors().items().get(0).message();
error.getErrors().iterator().next().getMessage()); assertTrue(error.getErrors().items().get(0).toString().contains("'name' is required"));
} }
FakeExtension createFakeExtension(String name, Long version) { FakeExtension createFakeExtension(String name, Long version) {

View File

@ -63,7 +63,7 @@ class SchemeTest {
scheme.groupVersionKind()); scheme.groupVersionKind());
assertEquals("fake", scheme.singular()); assertEquals("fake", scheme.singular());
assertEquals("fakes", scheme.plural()); assertEquals("fakes", scheme.plural());
assertNotNull(scheme.jsonSchema()); assertNotNull(scheme.openApiSchema());
assertEquals(FakeExtension.class, scheme.type()); assertEquals(FakeExtension.class, scheme.type());
} }