Create unstructured Extension which can store any Extensions (#2111)

* Extract ExtensionOperator and MetadataOperator

* Move groupVersionKind methods up to ExtensionOperator interface

* Add Unstructured Extension for generic Extension

* Refine mapping of GVK and Scheme

* Add two compatible methods
pull/2119/head
John Niang 2022-05-26 16:38:10 +08:00 committed by GitHub
parent 0f4ae08fd8
commit b5d7f194ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 422 additions and 81 deletions

View File

@ -1,6 +1,5 @@
package run.halo.app.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
@ -11,33 +10,10 @@ import lombok.Data;
@Data
public abstract class AbstractExtension implements Extension {
@Schema(required = true)
private String apiVersion;
@Schema(required = true)
private String kind;
@Schema(required = true)
private Metadata metadata;
private MetadataOperator metadata;
@Override
public void groupVersionKind(GroupVersionKind gvk) {
this.apiVersion = gvk.groupVersion().toString();
this.kind = gvk.kind();
}
@Override
public GroupVersionKind groupVersionKind() {
return GroupVersionKind.fromAPIVersionAndKind(this.apiVersion, this.kind);
}
@Override
public void metadata(Metadata metadata) {
this.metadata = metadata;
}
@Override
public Metadata metadata() {
return this.metadata;
}
}

View File

@ -68,7 +68,7 @@ public class DefaultExtensionClient implements ExtensionClient {
@Override
public <E extends Extension> void create(E extension) {
extension.metadata().setCreationTimestamp(Instant.now());
extension.getMetadata().setCreationTimestamp(Instant.now());
var extensionStore = converter.convertTo(extension);
storeClient.create(extensionStore.getName(), extensionStore.getData());
}
@ -76,7 +76,7 @@ public class DefaultExtensionClient implements ExtensionClient {
@Override
public <E extends Extension> void update(E extension) {
var extensionStore = converter.convertTo(extension);
Assert.notNull(extension.metadata().getVersion(),
Assert.notNull(extension.getMetadata().getVersion(),
"Extension version must not be null when updating");
storeClient.update(extensionStore.getName(), extensionStore.getVersion(),
extensionStore.getData());

View File

@ -4,34 +4,6 @@ package run.halo.app.extension;
* Extension is an interface which represents an Extension. It contains setters and getters of
* GroupVersionKind and Metadata.
*/
public interface Extension {
/**
* Sets GroupVersionKind of the Extension.
*
* @param gvk is GroupVersionKind data.
*/
void groupVersionKind(GroupVersionKind gvk);
/**
* Gets GroupVersionKind of the Extension.
*
* @return GroupVersionKind of the Extension.
*/
GroupVersionKind groupVersionKind();
/**
* Sets metadata of the Extension.
*
* @param metadata metadata of the Extension.
*/
void metadata(Metadata metadata);
/**
* Gets metadata of the Extension.
*
* @return metadata of the Extension.
*/
Metadata metadata();
public interface Extension extends ExtensionOperator {
}

View File

@ -0,0 +1,76 @@
package run.halo.app.extension;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.swagger.v3.oas.annotations.media.Schema;
/**
* ExtensionOperator contains some getters and setters for required fields of Extension.
*
* @author johnniang
*/
public interface ExtensionOperator {
@Schema(required = true)
@JsonProperty("apiVersion")
String getApiVersion();
@Schema(required = true)
@JsonProperty("kind")
String getKind();
@Schema(required = true, implementation = Metadata.class)
@JsonProperty("metadata")
MetadataOperator getMetadata();
void setApiVersion(String apiVersion);
void setKind(String kind);
void setMetadata(MetadataOperator metadata);
/**
* This method is only for backward compatibility. Same as {@link #getMetadata()}.
*
* @return Extension metadata.
* @see #getMetadata()
*/
@JsonIgnore
@Deprecated(forRemoval = true)
default MetadataOperator metadata() {
return getMetadata();
}
/**
* This method is only for backward compatibility. Same as
* {@link #setMetadata(MetadataOperator)}.
*
* @param metadata is Extension metadata.
* @see #setMetadata(MetadataOperator)
*/
@Deprecated(forRemoval = true)
default void metadata(MetadataOperator metadata) {
setMetadata(metadata);
}
/**
* Sets GroupVersionKind of the Extension.
*
* @param gvk is GroupVersionKind data.
*/
default void groupVersionKind(GroupVersionKind gvk) {
setApiVersion(gvk.groupVersion().toString());
setKind(gvk.kind());
}
/**
* Gets GroupVersionKind of the Extension.
*
* @return GroupVersionKind of the Extension.
*/
@JsonIgnore
default GroupVersionKind groupVersionKind() {
return GroupVersionKind.fromAPIVersionAndKind(getApiVersion(), getKind());
}
}

View File

@ -33,8 +33,9 @@ public class JSONExtensionConverter implements ExtensionConverter {
@Override
public <E extends Extension> ExtensionStore convertTo(E extension) {
var scheme = Schemes.INSTANCE.get(extension.getClass());
var storeName = ExtensionUtil.buildStoreName(scheme, extension.metadata().getName());
var gvk = extension.groupVersionKind();
var scheme = Schemes.INSTANCE.get(gvk);
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
try {
if (logger.isDebugEnabled()) {
logger.debug("JSON schema({}): {}", scheme.type(),
@ -56,7 +57,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
// keep converting
var data = objectMapper.writeValueAsBytes(extensionNode);
var version = extension.metadata().getVersion();
var version = extension.getMetadata().getVersion();
return new ExtensionStore(storeName, data, version);
} catch (JsonProcessingException e) {
throw new ExtensionConvertException("Failed write Extension as bytes", e);
@ -67,7 +68,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
public <E extends Extension> E convertFrom(Class<E> type, ExtensionStore extensionStore) {
try {
var extension = objectMapper.readValue(extensionStore.getData(), type);
extension.metadata().setVersion(extensionStore.getVersion());
extension.getMetadata().setVersion(extensionStore.getVersion());
return extension;
} catch (IOException e) {
throw new ExtensionConvertException("Failed to read Extension " + type + " from bytes",

View File

@ -1,6 +1,5 @@
package run.halo.app.extension;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
import lombok.Data;
@ -11,42 +10,36 @@ import lombok.Data;
* @author johnniang
*/
@Data
public class Metadata {
public class Metadata implements MetadataOperator {
/**
* Metadata name. The name is unique globally.
*/
@Schema(required = true)
private String name;
/**
* Labels are like key-value format.
*/
@Schema(nullable = true)
private Map<String, String> labels;
/**
* Annotations are like key-value format.
*/
@Schema(nullable = true)
private Map<String, String> annotations;
/**
* Current version of the Extension. It will be bumped up every update.
*/
@Schema(nullable = true)
private Long version;
/**
* Creation timestamp of the Extension.
*/
@Schema(nullable = true)
private Instant creationTimestamp;
/**
* Deletion timestamp of the Extension.
*/
@Schema(nullable = true)
private Instant deletionTimestamp;
}

View File

@ -0,0 +1,54 @@
package run.halo.app.extension;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
/**
* MetadataOperator contains some getters and setters for required fields of metadata.
*
* @author johnniang
*/
@JsonDeserialize(as = Metadata.class)
@Schema(implementation = Metadata.class)
public interface MetadataOperator {
@Schema(required = true)
@JsonProperty("name")
String getName();
@Schema(nullable = true)
@JsonProperty("labels")
Map<String, String> getLabels();
@Schema(nullable = true)
@JsonProperty("annotations")
Map<String, String> getAnnotations();
@Schema(nullable = true)
@JsonProperty("version")
Long getVersion();
@Schema(nullable = true)
@JsonProperty("creationTimestamp")
Instant getCreationTimestamp();
@Schema(nullable = true)
@JsonProperty("deletionTimestamp")
Instant getDeletionTimestamp();
void setName(String name);
void setLabels(Map<String, String> labels);
void setAnnotations(Map<String, String> annotations);
void setVersion(Long version);
void setCreationTimestamp(Instant creationTimestamp);
void setDeletionTimestamp(Instant deletionTimestamp);
}

View File

@ -1,5 +1,6 @@
package run.halo.app.extension;
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;
@ -39,23 +40,22 @@ public enum Schemes {
/**
* The map mapping GroupVersionKind and type of Extension.
*/
private final Map<GroupVersionKind, Class<? extends Extension>> gvkToType;
private final Map<GroupVersionKind, Scheme> gvkToScheme;
Schemes() {
schemes = new HashSet<>();
typeToScheme = new HashMap<>();
gvkToType = new HashMap<>();
gvkToScheme = new HashMap<>();
}
/**
* Clear registered schemes.
* <p>
* This method is only for test.
*/
void clear() {
schemes.clear();
typeToScheme.clear();
gvkToType.clear();
gvkToScheme.clear();
}
/**
@ -74,10 +74,17 @@ public enum Schemes {
type.getName()));
}
// 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);
@ -102,7 +109,7 @@ public enum Schemes {
return;
}
typeToScheme.put(scheme.type(), scheme);
gvkToType.put(scheme.groupVersionKind(), scheme.type());
gvkToScheme.put(scheme.groupVersionKind(), scheme);
}
/**
@ -116,6 +123,10 @@ public enum Schemes {
}
public Optional<Scheme> fetch(GroupVersionKind gvk) {
return Optional.ofNullable(gvkToScheme.get(gvk));
}
/**
* Gets a scheme using Extension type.
*
@ -128,4 +139,9 @@ public enum Schemes {
"Scheme was not found for Extension " + type.getSimpleName()));
}
public Scheme get(GroupVersionKind gvk) {
return fetch(gvk).orElseThrow(() -> new SchemeNotFoundException(
"Scheme was not found for GVK " + gvk));
}
}

View File

@ -0,0 +1,100 @@
package run.halo.app.extension;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.JsonSerializer;
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 com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.io.IOException;
/**
* Unstructured is a generic Extension, which wraps ObjectNode to maintain the Extension data, like
* apiVersion, kind, metadata and others.
*
* @author johnniang
*/
@JsonSerialize(using = Unstructured.UnstructuredSerializer.class)
@JsonDeserialize(using = Unstructured.UnstructuredDeserializer.class)
public class Unstructured implements Extension {
public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
static {
OBJECT_MAPPER.registerModule(new JavaTimeModule());
}
private final ObjectNode extension;
public Unstructured() {
this(OBJECT_MAPPER.createObjectNode());
}
public Unstructured(ObjectNode extension) {
this.extension = extension;
}
@Override
public String getApiVersion() {
return extension.get("apiVersion").asText();
}
@Override
public String getKind() {
return extension.get("kind").asText();
}
@Override
public MetadataOperator getMetadata() {
var metaMap = extension.get("metadata");
return OBJECT_MAPPER.convertValue(metaMap, Metadata.class);
}
@Override
public void setApiVersion(String apiVersion) {
extension.put("apiVersion", apiVersion);
}
@Override
public void setKind(String kind) {
extension.put("kind", kind);
}
@Override
public void setMetadata(MetadataOperator metadata) {
JsonNode metaNode = OBJECT_MAPPER.valueToTree(metadata);
extension.set("metadata", metaNode);
}
ObjectNode getExtension() {
return extension;
}
// TODO Add other convenient methods here to set and get nested fields in the future.
public static class UnstructuredSerializer extends JsonSerializer<Unstructured> {
@Override
public void serialize(Unstructured value, JsonGenerator gen, SerializerProvider serializers)
throws IOException {
gen.writeTree(value.extension);
}
}
public static class UnstructuredDeserializer extends JsonDeserializer<Unstructured> {
@Override
public Unstructured deserialize(JsonParser p, DeserializationContext ctxt)
throws IOException {
return new Unstructured(p.getCodec().readTree(p));
}
}
}

View File

@ -45,7 +45,7 @@ class AbstractExtensionTest {
Metadata metadata = new Metadata();
metadata.setName("fake");
extension.metadata(metadata);
extension.setMetadata(metadata);
assertEquals(metadata, extension.getMetadata());
}

View File

@ -13,6 +13,7 @@ import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.BeforeAll;
@ -58,11 +59,35 @@ class DefaultExtensionClientTest {
}
ExtensionStore createExtensionStore(String name) {
return createExtensionStore(name, null);
}
ExtensionStore createExtensionStore(String name, Long version) {
var extensionStore = new ExtensionStore();
extensionStore.setName(name);
extensionStore.setVersion(version);
return extensionStore;
}
Unstructured createUnstructured() throws JsonProcessingException {
String extensionJson = """
{
"apiVersion": "fake.halo.run/v1alpha1",
"kind": "Fake",
"metadata": {
"labels": {
"category": "fake",
"default": "true"
},
"name": "fake",
"creationTimestamp": "2011-12-03T10:15:30Z",
"version": 12345
}
}
""";
return Unstructured.OBJECT_MAPPER.readValue(extensionJson, Unstructured.class);
}
@Test
void shouldThrowSchemeNotFoundExceptionWhenSchemeNotRegistered() {
class UnRegisteredExtension extends AbstractExtension {
@ -205,8 +230,24 @@ class DefaultExtensionClientTest {
client.create(fake);
verify(converter, times(1)).convertTo(any());
verify(storeClient, times(1)).create(any(), any());
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any());
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@Test
void shouldCreateUsingUnstructuredSuccessfully() throws JsonProcessingException {
var fake = createUnstructured();
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.create(any(), any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
client.create(fake);
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any());
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@ -214,14 +255,30 @@ class DefaultExtensionClientTest {
void shouldUpdateSuccessfully() {
var fake = createFakeExtension("fake", 2L);
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
createExtensionStore("/registry/fake.halo.run/fakes/fake", 2L));
when(storeClient.update(any(), any(), any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
createExtensionStore("/registry/fake.halo.run/fakes/fake", 2L));
client.update(fake);
verify(converter, times(1)).convertTo(any());
verify(storeClient, times(1)).update(any(), any(), any());
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1))
.update(eq("/registry/fake.halo.run/fakes/fake"), eq(2L), any());
}
@Test
void shouldUpdateUnstructuredSuccessfully() throws JsonProcessingException {
var fake = createUnstructured();
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake", 12345L));
when(storeClient.update(any(), any(), any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake", 12345L));
client.update(fake);
verify(converter, times(1)).convertTo(eq(fake));
verify(storeClient, times(1))
.update(eq("/registry/fake.halo.run/fakes/fake"), eq(12345L), any());
}
@Test

View File

@ -87,7 +87,7 @@ class JSONExtensionConverterTest {
Metadata metadata = new Metadata();
metadata.setName(name);
metadata.setVersion(version);
fake.metadata(metadata);
fake.setMetadata(metadata);
return fake;
}

View File

@ -3,11 +3,13 @@ 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 static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind;
import java.util.Optional;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import run.halo.app.extension.exception.ExtensionException;
import run.halo.app.extension.exception.SchemeNotFoundException;
class SchemesTest {
@ -35,6 +37,13 @@ class SchemesTest {
void shouldFetchNothingWhenUnregistered() {
var scheme = Schemes.INSTANCE.fetch(FakeExtension.class);
assertEquals(Optional.empty(), scheme);
assertThrows(SchemeNotFoundException.class,
() -> Schemes.INSTANCE.get(FakeExtension.class));
var gvk = fromAPIVersionAndKind("fake.halo.run/v1alpha1", "Fake");
scheme = Schemes.INSTANCE.fetch(gvk);
assertEquals(Optional.empty(), scheme);
assertThrows(SchemeNotFoundException.class, () -> Schemes.INSTANCE.get(gvk));
}
@Test
@ -43,5 +52,8 @@ class SchemesTest {
var scheme = Schemes.INSTANCE.fetch(FakeExtension.class);
assertTrue(scheme.isPresent());
scheme = Schemes.INSTANCE.fetch(fromAPIVersionAndKind("fake.halo.run/v1alpha1", "Fake"));
assertTrue(scheme.isPresent());
}
}

View File

@ -0,0 +1,84 @@
package run.halo.app.extension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.time.Instant;
import java.util.Map;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
class UnstructuredTest {
ObjectMapper objectMapper = Unstructured.OBJECT_MAPPER;
String extensionJson = """
{
"apiVersion": "fake.halo.run/v1alpha1",
"kind": "Fake",
"metadata": {
"labels": {
"category": "fake",
"default": "true"
},
"name": "fake-extension",
"creationTimestamp": "2011-12-03T10:15:30Z",
"version": 12345
}
}
""";
@BeforeAll
static void setUpGlobally() {
Schemes.INSTANCE.register(FakeExtension.class);
}
@Test
void shouldSerializeCorrectly() throws JsonProcessingException {
var extensionNode = (ObjectNode) objectMapper.readTree(extensionJson);
var extension = new Unstructured(extensionNode);
var gotNode = objectMapper.valueToTree(extension);
assertEquals(extensionNode, gotNode);
}
@Test
void shouldDeserializeCorrectly() throws JsonProcessingException {
var extension = objectMapper.readValue(extensionJson, Unstructured.class);
var wantJsonNode = objectMapper.readTree(extensionJson);
assertEquals(wantJsonNode, extension.getExtension());
}
@Test
void shouldGetExtensionCorrectly() throws JsonProcessingException {
var extension = objectMapper.readValue(extensionJson, Unstructured.class);
assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion());
assertEquals("Fake", extension.getKind());
assertEquals(createMetadata(), extension.getMetadata());
}
@Test
void shouldSetExtensionCorrectly() {
var extension = new Unstructured();
extension.setApiVersion("fake.halo.run/v1alpha1");
extension.setKind("Fake");
extension.setMetadata(createMetadata());
assertEquals("fake.halo.run/v1alpha1", extension.getApiVersion());
assertEquals("Fake", extension.getKind());
assertEquals(createMetadata(), extension.getMetadata());
}
private Metadata createMetadata() {
var metadata = new Metadata();
metadata.setName("fake-extension");
metadata.setLabels(Map.of("category", "fake", "default", "true"));
metadata.setCreationTimestamp(Instant.parse("2011-12-03T10:15:30Z"));
metadata.setVersion(12345L);
return metadata;
}
}