diff --git a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java index 64857d0e3..43848bbda 100644 --- a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java +++ b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -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 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( + 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 { + + 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 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; + } + } + } diff --git a/src/main/java/run/halo/app/extension/Metadata.java b/src/main/java/run/halo/app/extension/Metadata.java index 79be6e432..a13f03666 100644 --- a/src/main/java/run/halo/app/extension/Metadata.java +++ b/src/main/java/run/halo/app/extension/Metadata.java @@ -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. */ diff --git a/src/main/java/run/halo/app/extension/MetadataOperator.java b/src/main/java/run/halo/app/extension/MetadataOperator.java index 39e8e1042..b3d88a58d 100644 --- a/src/main/java/run/halo/app/extension/MetadataOperator.java +++ b/src/main/java/run/halo/app/extension/MetadataOperator.java @@ -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 getLabels(); - @Schema(nullable = true) + @Schema(name = "annotations", nullable = true) @JsonProperty("annotations") Map 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 getFinalizers(); void setName(String name); + void setGenerateName(String generateName); + void setLabels(Map labels); void setAnnotations(Map annotations); diff --git a/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java b/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java index d9bb54701..9afbf1cef 100644 --- a/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java +++ b/src/main/java/run/halo/app/extension/ReactiveExtensionClientImpl.java @@ -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 Mono 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) 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) 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 diff --git a/src/main/java/run/halo/app/extension/Unstructured.java b/src/main/java/run/halo/app/extension/Unstructured.java index 467d67187..5719926ae 100644 --- a/src/main/java/run/halo/app/extension/Unstructured.java +++ b/src/main/java/run/halo/app/extension/Unstructured.java @@ -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 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 labels) { setNestedValue(data, labels, "metadata", "labels"); diff --git a/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java b/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java index 02d5a7f11..04a280370 100644 --- a/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java +++ b/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java @@ -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) { diff --git a/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java b/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java index 8070542c2..edc2786e9 100644 --- a/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java +++ b/src/test/java/run/halo/app/extension/ReactiveExtensionClientTest.java @@ -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();