mirror of https://github.com/halo-dev/halo
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
parent
58e98f0fc8
commit
299634b756
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in New Issue