Enable generating metadata name using generateName (#2563)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.0

#### What this PR does / why we need it:

See https://github.com/halo-dev/halo/issues/2309 for more.

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/2309

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
新增 generateName 字段用于自动生成自定义模型名称
```
pull/2593/head
John Niang 2022-10-18 14:24:10 +08:00 committed by GitHub
parent 58e98f0fc8
commit 299634b756
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 190 additions and 26 deletions

View File

@ -1,13 +1,26 @@
package run.halo.app.extension;
import static org.openapi4j.core.validation.ValidationSeverity.ERROR;
import static org.springframework.util.StringUtils.arrayToCommaDelimitedString;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.core.util.Json;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import lombok.extern.slf4j.Slf4j;
import org.openapi4j.core.exception.ResolutionException;
import org.openapi4j.core.model.v3.OAI3;
import org.openapi4j.core.model.v3.OAI3Context;
import org.openapi4j.core.validation.ValidationResult;
import org.openapi4j.core.validation.ValidationResults;
import org.openapi4j.schema.validator.BaseJsonValidator;
import org.openapi4j.schema.validator.ValidationContext;
import org.openapi4j.schema.validator.ValidationData;
import org.openapi4j.schema.validator.v3.SchemaValidator;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import run.halo.app.extension.exception.ExtensionConvertException;
import run.halo.app.extension.exception.SchemaViolationException;
import run.halo.app.extension.store.ExtensionStore;
@ -38,14 +51,13 @@ public class JSONExtensionConverter implements ExtensionConverter {
public <E extends Extension> ExtensionStore convertTo(E extension) {
var gvk = extension.groupVersionKind();
var scheme = schemeManager.get(gvk);
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
try {
var data = objectMapper.writeValueAsBytes(extension);
var extensionJsonNode = objectMapper.valueToTree(extension);
var validator = new SchemaValidator(null, scheme.openApiSchema());
var validation = new ValidationData<>(extension);
var extensionJsonNode = objectMapper.valueToTree(extension);
var validator = getValidator(scheme);
validator.validate(extensionJsonNode, validation);
if (!validation.isValid()) {
log.debug("Failed to validate Extension: {}, and errors were: {}",
@ -55,6 +67,7 @@ public class JSONExtensionConverter implements ExtensionConverter {
}
var version = extension.getMetadata().getVersion();
var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName());
return new ExtensionStore(storeName, data, version);
} catch (IOException e) {
throw new ExtensionConvertException("Failed write Extension as bytes", e);
@ -75,4 +88,52 @@ public class JSONExtensionConverter implements ExtensionConverter {
}
}
private SchemaValidator getValidator(Scheme scheme)
throws MalformedURLException, ResolutionException {
var context = new ValidationContext<OAI3>(
new OAI3Context(new URL("file:/"), scheme.openApiSchema()));
context.addValidator("x-validation", ExtraValidationValidator::new);
context.setFastFail(false);
return new SchemaValidator(context, null, scheme.openApiSchema());
}
public static class ExtraValidationValidator extends BaseJsonValidator<OAI3> {
private String[] fieldNames;
private static final ValidationResult ERR =
new ValidationResult(ERROR, 1100, "Fields '%s' should not be blank at the same time");
private static final ValidationResults.CrumbInfo CRUMB_INFO =
new ValidationResults.CrumbInfo("not-blank-at-least-one", true);
protected ExtraValidationValidator(ValidationContext<OAI3> context,
JsonNode schemaNode, JsonNode schemaParentNode,
SchemaValidator parentSchema) {
super(context, schemaNode, schemaParentNode, parentSchema);
var withNode = schemaNode.get("not-blank-at-least-one");
if (withNode != null && withNode.isTextual()) {
fieldNames = StringUtils.commaDelimitedListToStringArray(withNode.asText());
withNode.asText();
}
}
@Override
public boolean validate(JsonNode valueNode, ValidationData<?> validation) {
if (fieldNames == null) {
return false;
}
for (var fieldName : fieldNames) {
JsonNode value = valueNode.get(fieldName);
if (value != null && value.isTextual() && StringUtils.hasText(value.asText())) {
return false;
}
}
// or all of them are blank string
validation.add(CRUMB_INFO, ERR, arrayToCommaDelimitedString(fieldNames));
return false;
}
}
}

View File

@ -1,5 +1,8 @@
package run.halo.app.extension;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.Map;
import java.util.Set;
@ -13,6 +16,11 @@ import lombok.EqualsAndHashCode;
*/
@Data
@EqualsAndHashCode
@Schema(description = "Metadata information", extensions = {
@Extension(name = "x-validation", properties = {
@ExtensionProperty(name = "not-blank-at-least-one", value = "name, generateName")
})}
)
public class Metadata implements MetadataOperator {
/**
@ -20,6 +28,11 @@ public class Metadata implements MetadataOperator {
*/
private String name;
/**
* Generate name is for generating metadata name automatically.
*/
private String generateName;
/**
* Labels are like key-value format.
*/

View File

@ -17,35 +17,41 @@ import java.util.Set;
@Schema(implementation = Metadata.class)
public interface MetadataOperator {
@Schema(required = true)
@Schema(name = "name", description = "Metadata name")
@JsonProperty("name")
String getName();
@Schema(nullable = true)
@Schema(name = "generateName", description = "The name field will be generated automatically "
+ "according to the given generateName field")
String getGenerateName();
@Schema(name = "labels", nullable = true)
@JsonProperty("labels")
Map<String, String> getLabels();
@Schema(nullable = true)
@Schema(name = "annotations", nullable = true)
@JsonProperty("annotations")
Map<String, String> getAnnotations();
@Schema(nullable = true)
@Schema(name = "version", nullable = true)
@JsonProperty("version")
Long getVersion();
@Schema(nullable = true)
@Schema(name = "creationTimestamp", nullable = true)
@JsonProperty("creationTimestamp")
Instant getCreationTimestamp();
@Schema(nullable = true)
@Schema(name = "deletionTimestamp", nullable = true)
@JsonProperty("deletionTimestamp")
Instant getDeletionTimestamp();
@Schema(nullable = true)
@Schema(name = "finalizers", nullable = true)
Set<String> getFinalizers();
void setName(String name);
void setGenerateName(String generateName);
void setLabels(Map<String, String> labels);
void setAnnotations(Map<String, String> annotations);

View File

@ -1,13 +1,19 @@
package run.halo.app.extension;
import static org.apache.commons.lang3.RandomStringUtils.randomAlphabetic;
import static org.springframework.util.StringUtils.hasText;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.function.Predicate;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.util.Predicates;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.extension.exception.ExtensionNotFoundException;
import run.halo.app.extension.store.ReactiveExtensionStoreClient;
@ -90,17 +96,33 @@ public class ReactiveExtensionClientImpl implements ReactiveExtensionClient {
@Override
public <E extends Extension> Mono<E> create(E extension) {
var metadata = extension.getMetadata();
// those fields should be managed by halo.
metadata.setCreationTimestamp(Instant.now());
metadata.setDeletionTimestamp(null);
metadata.setVersion(null);
return Mono.just(extension)
.doOnNext(ext -> {
var metadata = extension.getMetadata();
// those fields should be managed by halo.
metadata.setCreationTimestamp(Instant.now());
metadata.setDeletionTimestamp(null);
metadata.setVersion(null);
var extensionStore = converter.convertTo(extension);
return client.create(extensionStore.getName(), extensionStore.getData())
.map(created -> converter.convertFrom((Class<E>) extension.getClass(), created))
.doOnNext(watchers::onAdd);
if (!hasText(metadata.getName())) {
if (!hasText(metadata.getGenerateName())) {
throw new IllegalArgumentException(
"The metadata.generateName must not be blank when metadata.name is "
+ "blank");
}
// generate name with random text
metadata.setName(metadata.getGenerateName() + randomAlphabetic(5));
}
extension.setMetadata(metadata);
})
.map(converter::convertTo)
.flatMap(extStore -> client.create(extStore.getName(), extStore.getData())
.map(created -> converter.convertFrom((Class<E>) extension.getClass(), created))
.doOnNext(watchers::onAdd))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
// retry when generateName is set
.filter(t -> t instanceof DataIntegrityViolationException
&& hasText(extension.getMetadata().getGenerateName())));
}
@Override

View File

@ -67,6 +67,11 @@ public class Unstructured implements Extension {
return (String) getNestedValue(data, "metadata", "name").orElse(null);
}
@Override
public String getGenerateName() {
return (String) getNestedValue(data, "metadata", "generateName").orElse(null);
}
@Override
public Map<String, String> getLabels() {
return getNestedStringStringMap(data, "metadata", "labels").orElse(null);
@ -102,6 +107,11 @@ public class Unstructured implements Extension {
setNestedValue(data, name, "metadata", "name");
}
@Override
public void setGenerateName(String generateName) {
setNestedValue(data, generateName, "metadata", "generateName");
}
@Override
public void setLabels(Map<String, String> labels) {
setNestedValue(data, labels, "metadata", "labels");

View File

@ -84,8 +84,9 @@ class JsonExtensionConverterTest {
fake.setKind("Fake");
var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake));
assertEquals(1, error.getErrors().size());
var result = error.getErrors().items().get(0);
// error.getErrors().items().get(0).message();
assertTrue(error.getErrors().items().get(0).toString().contains("'name' is required"));
assertTrue(result.toString().contains("name, generateName"));
}
FakeExtension createFakeExtension(String name, Long version) {

View File

@ -7,6 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.ArgumentMatchers.same;
@ -28,6 +29,8 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DataIntegrityViolationException;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
@ -120,10 +123,11 @@ class ReactiveExtensionClientTest {
() -> client.fetch(UnRegisteredExtension.class, "fake"));
assertThrows(SchemeNotFoundException.class, () ->
client.fetch(fromAPIVersionAndKind("fake.halo.run/v1alpha1", "UnRegistered"), "fake"));
assertThrows(SchemeNotFoundException.class, () -> {
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
client.create(createFakeExtension("fake", null));
});
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
StepVerifier.create(client.create(createFakeExtension("fake", null)))
.verifyError(SchemeNotFoundException.class);
assertThrows(SchemeNotFoundException.class, () -> {
when(converter.convertTo(any())).thenThrow(SchemeNotFoundException.class);
client.update(createFakeExtension("fake", 1L));
@ -322,6 +326,53 @@ class ReactiveExtensionClientTest {
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@Test
void shouldCreateWithGenerateNameSuccessfully() {
var fake = createFakeExtension("fake", null);
fake.getMetadata().setName("");
fake.getMetadata().setGenerateName("fake-");
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.create(any(), any())).thenReturn(
Mono.just(createExtensionStore("/registry/fake.halo.run/fakes/fake")));
when(converter.convertFrom(same(FakeExtension.class), any())).thenReturn(fake);
StepVerifier.create(client.create(fake))
.expectNext(fake)
.verifyComplete();
verify(converter, times(1)).convertTo(argThat(ext -> {
var name = ext.getMetadata().getName();
return name.startsWith(ext.getMetadata().getGenerateName());
}));
verify(storeClient, times(1)).create(eq("/registry/fake.halo.run/fakes/fake"), any());
assertNotNull(fake.getMetadata().getCreationTimestamp());
}
@Test
void shouldThrowExceptionIfCreatingWithoutGenerateName() {
var fake = createFakeExtension("fake", null);
fake.getMetadata().setName("");
fake.getMetadata().setGenerateName("");
StepVerifier.create(client.create(fake))
.verifyError(IllegalArgumentException.class);
}
@Test
void shouldThrowExceptionIfPrimaryKeyDuplicated() {
var fake = createFakeExtension("fake", null);
fake.getMetadata().setName("");
fake.getMetadata().setGenerateName("fake-");
when(converter.convertTo(any())).thenReturn(
createExtensionStore("/registry/fake.halo.run/fakes/fake"));
when(storeClient.create(any(), any())).thenThrow(DataIntegrityViolationException.class);
StepVerifier.create(client.create(fake))
.expectErrorMatches(Exceptions::isRetryExhausted)
.verify();
}
@Test
void shouldCreateUsingUnstructuredSuccessfully() throws JsonProcessingException {
var fake = createUnstructured();