diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 967553725f..859ef4cf25 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -9,7 +9,6 @@ import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.content.ContentService; import run.halo.app.content.PostService; -import run.halo.app.content.SinglePageService; import run.halo.app.content.permalinks.CategoryPermalinkPolicy; import run.halo.app.content.permalinks.PostPermalinkPolicy; import run.halo.app.content.permalinks.TagPermalinkPolicy; @@ -151,11 +150,11 @@ Controller themeController(ExtensionClient client, HaloProperties haloProperties @Bean Controller postController(ExtensionClient client, ContentService contentService, PostPermalinkPolicy postPermalinkPolicy, CounterService counterService, - PostService postService) { + PostService postService, ApplicationContext applicationContext) { return new ControllerBuilder("post", client) .reconciler(new PostReconciler(client, contentService, postService, postPermalinkPolicy, - counterService)) + counterService, applicationContext)) .extension(new Post()) // TODO Make it configurable .workerCount(10) @@ -204,10 +203,10 @@ Controller attachmentController(ExtensionClient client, @Bean Controller singlePageController(ExtensionClient client, ContentService contentService, ApplicationContext applicationContext, CounterService counterService, - SinglePageService singlePageService, ExternalUrlSupplier externalUrlSupplier) { + ExternalUrlSupplier externalUrlSupplier) { return new ControllerBuilder("single-page", client) .reconciler(new SinglePageReconciler(client, contentService, - applicationContext, singlePageService, counterService, externalUrlSupplier) + applicationContext, counterService, externalUrlSupplier) ) .extension(new SinglePage()) .build(); diff --git a/src/main/java/run/halo/app/content/ContentRequest.java b/src/main/java/run/halo/app/content/ContentRequest.java index 328341f566..6848135d64 100644 --- a/src/main/java/run/halo/app/content/ContentRequest.java +++ b/src/main/java/run/halo/app/content/ContentRequest.java @@ -1,6 +1,7 @@ package run.halo.app.content; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.HashMap; import org.apache.commons.lang3.StringUtils; import run.halo.app.core.extension.Snapshot; import run.halo.app.extension.Metadata; @@ -17,31 +18,23 @@ public record ContentRequest(@Schema(required = true) Ref subjectRef, @Schema(required = true) String rawType) { public Snapshot toSnapshot() { - Snapshot snapshot = new Snapshot(); + final Snapshot snapshot = new Snapshot(); Metadata metadata = new Metadata(); - metadata.setName(defaultName(subjectRef)); + metadata.setAnnotations(new HashMap<>()); snapshot.setMetadata(metadata); Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec(); snapShotSpec.setSubjectRef(subjectRef); - snapShotSpec.setVersion(1); + snapShotSpec.setRawType(rawType); snapShotSpec.setRawPatch(StringUtils.defaultString(raw())); snapShotSpec.setContentPatch(StringUtils.defaultString(content())); - String displayVersion = Snapshot.displayVersionFrom(snapShotSpec.getVersion()); - snapShotSpec.setDisplayVersion(displayVersion); snapshot.setSpec(snapShotSpec); return snapshot; } - private String defaultName(Ref subjectRef) { - // example: Post-apost-v1-snapshot - return String.join("-", subjectRef.getKind(), - subjectRef.getName(), "v1", "snapshot"); - } - public String rawPatchFrom(String originalRaw) { // originalRaw content from v1 return PatchUtils.diffToJsonPatch(originalRaw, this.raw); diff --git a/src/main/java/run/halo/app/content/ContentService.java b/src/main/java/run/halo/app/content/ContentService.java index 64d3fecb4e..038ec150c5 100644 --- a/src/main/java/run/halo/app/content/ContentService.java +++ b/src/main/java/run/halo/app/content/ContentService.java @@ -17,15 +17,13 @@ public interface ContentService { Mono draftContent(ContentRequest content); - Mono updateContent(ContentRequest content); + Mono draftContent(ContentRequest content, String parentName); - Mono publish(String headSnapshotName, Ref subjectRef); + Mono updateContent(ContentRequest content); Mono getBaseSnapshot(Ref subjectRef); Mono latestSnapshotVersion(Ref subjectRef); - Mono latestPublishedSnapshot(Ref subjectRef); - Flux listSnapshots(Ref subjectRef); } diff --git a/src/main/java/run/halo/app/content/ContentWrapper.java b/src/main/java/run/halo/app/content/ContentWrapper.java index 6e78230227..df96bc4c1e 100644 --- a/src/main/java/run/halo/app/content/ContentWrapper.java +++ b/src/main/java/run/halo/app/content/ContentWrapper.java @@ -11,7 +11,6 @@ @Builder public class ContentWrapper { private String snapshotName; - private Integer version; private String raw; private String content; private String rawType; diff --git a/src/main/java/run/halo/app/content/PostService.java b/src/main/java/run/halo/app/content/PostService.java index 95a4b560bf..873ea6d7fe 100644 --- a/src/main/java/run/halo/app/content/PostService.java +++ b/src/main/java/run/halo/app/content/PostService.java @@ -17,6 +17,4 @@ public interface PostService { Mono draftPost(PostRequest postRequest); Mono updatePost(PostRequest postRequest); - - Mono publishPost(String postName); } diff --git a/src/main/java/run/halo/app/content/SinglePageService.java b/src/main/java/run/halo/app/content/SinglePageService.java index 29aeda2eba..b88039022d 100644 --- a/src/main/java/run/halo/app/content/SinglePageService.java +++ b/src/main/java/run/halo/app/content/SinglePageService.java @@ -17,6 +17,4 @@ public interface SinglePageService { Mono draft(SinglePageRequest pageRequest); Mono update(SinglePageRequest pageRequest); - - Mono publish(String name); } diff --git a/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java index d1268c72d3..40372832b1 100644 --- a/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java +++ b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java @@ -3,13 +3,13 @@ import java.security.Principal; import java.time.Instant; import java.util.Comparator; -import java.util.Map; import java.util.UUID; -import org.apache.commons.lang3.StringUtils; +import java.util.function.Function; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; import org.springframework.stereotype.Component; import org.springframework.util.Assert; +import org.thymeleaf.util.StringUtils; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import run.halo.app.content.ContentRequest; @@ -28,10 +28,6 @@ */ @Component public class ContentServiceImpl implements ContentService { - private static final Comparator SNAPSHOT_COMPARATOR = - Comparator.comparing(snapshot -> snapshot.getSpec().getVersion()); - public static Comparator LATEST_SNAPSHOT_COMPARATOR = SNAPSHOT_COMPARATOR.reversed(); - private final ReactiveExtensionClient client; public ContentServiceImpl(ReactiveExtensionClient client) { @@ -46,61 +42,56 @@ public Mono getContent(String name) { } @Override - public Mono draftContent(ContentRequest contentRequest) { - return getContextUsername() - .flatMap(username -> { - // create snapshot - Snapshot snapshot = contentRequest.toSnapshot(); - snapshot.addContributor(username); - return client.create(snapshot) - .flatMap(this::restoredContent); - }); + public Mono draftContent(ContentRequest content) { + return this.draftContent(content, content.headSnapshotName()); + } + + @Override + public Mono draftContent(ContentRequest contentRequest, String parentName) { + return Mono.defer( + () -> { + Snapshot snapshot = contentRequest.toSnapshot(); + snapshot.getMetadata().setName(UUID.randomUUID().toString()); + snapshot.getSpec().setParentSnapshotName(parentName); + return getBaseSnapshot(contentRequest.subjectRef()) + .defaultIfEmpty(snapshot) + .map(baseSnapshot -> determineRawAndContentPatch(snapshot, baseSnapshot, + contentRequest)) + .flatMap(source -> getContextUsername() + .map(username -> { + Snapshot.addContributor(source, username); + source.getSpec().setOwner(username); + return source; + }) + .defaultIfEmpty(source) + ); + }) + .flatMap(snapshot -> client.create(snapshot) + .flatMap(this::restoredContent)); } @Override public Mono updateContent(ContentRequest contentRequest) { Assert.notNull(contentRequest, "The contentRequest must not be null"); Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null"); - return Mono.zip(getContextUsername(), - client.fetch(Snapshot.class, contentRequest.headSnapshotName())) - .flatMap(tuple -> { - String username = tuple.getT1(); - Snapshot headSnapShot = tuple.getT2(); - return handleSnapshot(headSnapShot, contentRequest, username); - }) + Ref subjectRef = contentRequest.subjectRef(); + return client.fetch(Snapshot.class, contentRequest.headSnapshotName()) + .flatMap(headSnapshot -> getBaseSnapshot(subjectRef) + .map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot, + contentRequest) + ) + ) + .flatMap(headSnapshot -> getContextUsername() + .map(username -> { + Snapshot.addContributor(headSnapshot, username); + return headSnapshot; + }) + .defaultIfEmpty(headSnapshot) + ) + .flatMap(client::update) .flatMap(this::restoredContent); } - @Override - public Mono publish(String headSnapshotName, Ref subjectRef) { - Assert.notNull(headSnapshotName, "The headSnapshotName must not be null"); - return client.fetch(Snapshot.class, headSnapshotName) - .flatMap(snapshot -> { - if (snapshot.isPublished()) { - // there is nothing to publish - return restoredContent(snapshot.getMetadata().getName(), - subjectRef); - } - Map labels = ExtensionUtil.nullSafeLabels(snapshot); - Snapshot.putPublishedLabel(labels); - Snapshot.SnapShotSpec snapshotSpec = snapshot.getSpec(); - snapshotSpec.setPublishTime(Instant.now()); - snapshotSpec.setDisplayVersion( - Snapshot.displayVersionFrom(snapshotSpec.getVersion())); - return client.update(snapshot) - .then(Mono.defer( - () -> restoredContent(snapshot.getMetadata().getName(), subjectRef)) - ); - }); - } - - private Mono restoredContent(String snapshotName, - Ref subjectRef) { - return getBaseSnapshot(subjectRef) - .flatMap(baseSnapshot -> client.fetch(Snapshot.class, snapshotName) - .map(snapshot -> snapshot.applyPatch(baseSnapshot))); - } - private Mono restoredContent(Snapshot headSnapshot) { return getBaseSnapshot(headSnapshot.getSpec().getSubjectRef()) .map(headSnapshot::applyPatch); @@ -109,78 +100,17 @@ private Mono restoredContent(Snapshot headSnapshot) { @Override public Mono getBaseSnapshot(Ref subjectRef) { return listSnapshots(subjectRef) - .filter(snapshot -> snapshot.getSpec().getVersion() == 1) + .sort(createTimeReversedComparator().reversed()) + .filter(p -> StringUtils.equals(Boolean.TRUE.toString(), + ExtensionUtil.nullSafeAnnotations(p).get(Snapshot.KEEP_RAW_ANNO))) .next(); } - private Mono handleSnapshot(Snapshot headSnapshot, ContentRequest contentRequest, - String username) { - Ref subjectRef = contentRequest.subjectRef(); - return getBaseSnapshot(subjectRef).flatMap(baseSnapshot -> { - String baseSnapshotName = baseSnapshot.getMetadata().getName(); - return latestPublishedSnapshot(subjectRef) - .flatMap(latestReleasedSnapshot -> { - Snapshot newSnapshot = contentRequest.toSnapshot(); - newSnapshot.getSpec().setSubjectRef(subjectRef); - newSnapshot.addContributor(username); - // has released snapshot, there are 3 assumptions: - // if headPtr != releasePtr && head is not published, then update its content - // directly - // if headPtr != releasePtr && head is published, then create a new snapshot - // if headPtr == releasePtr, then create a new snapshot too - return latestSnapshotVersion(subjectRef) - .flatMap(latestSnapshot -> { - String headSnapshotName = contentRequest.headSnapshotName(); - newSnapshot.getSpec() - .setVersion(latestSnapshot.getSpec().getVersion() + 1); - newSnapshot.getSpec().setDisplayVersion( - Snapshot.displayVersionFrom(newSnapshot.getSpec().getVersion())); - newSnapshot.getSpec() - .setParentSnapshotName(headSnapshotName); - // head is published or headPtr == releasePtr - String releasedSnapshotName = - latestReleasedSnapshot.getMetadata().getName(); - if (headSnapshot.isPublished() || StringUtils.equals(headSnapshotName, - releasedSnapshotName)) { - String latestSnapshotName = latestSnapshot.getMetadata().getName(); - if (!headSnapshotName.equals(latestSnapshotName) - && !latestSnapshot.isPublished()) { - // publish it then create new one - return publish(latestSnapshotName, subjectRef) - .then(createNewSnapshot(newSnapshot, baseSnapshotName, - contentRequest)); - } - // create a new snapshot,done - return createNewSnapshot(newSnapshot, baseSnapshotName, - contentRequest); - } - - // otherwise update its content directly - return updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName, - contentRequest); - }); - }) - // no released snapshot, indicating v1 now, just update the content directly - .switchIfEmpty(Mono.defer( - () -> updateRawAndContentToHeadSnapshot(headSnapshot, baseSnapshotName, - contentRequest))); - }); - } - @Override public Mono latestSnapshotVersion(Ref subjectRef) { Assert.notNull(subjectRef, "The subjectRef must not be null."); return listSnapshots(subjectRef) - .sort(LATEST_SNAPSHOT_COMPARATOR) - .next(); - } - - @Override - public Mono latestPublishedSnapshot(Ref subjectRef) { - Assert.notNull(subjectRef, "The subjectRef must not be null."); - return listSnapshots(subjectRef) - .filter(Snapshot::isPublished) - .sort(LATEST_SNAPSHOT_COMPARATOR) + .sort(createTimeReversedComparator()) .next(); } @@ -197,41 +127,22 @@ private Mono getContextUsername() { .map(Principal::getName); } - private Mono updateRawAndContentToHeadSnapshot(Snapshot snapshotToUpdate, - String baseSnapshotName, - ContentRequest contentRequest) { - return client.fetch(Snapshot.class, baseSnapshotName) - .flatMap(baseSnapshot -> { - determineRawAndContentPatch(snapshotToUpdate, - baseSnapshot, contentRequest); - return client.update(snapshotToUpdate) - .thenReturn(snapshotToUpdate); - }); - } - - private Mono createNewSnapshot(Snapshot snapshotToCreate, - String baseSnapshotName, - ContentRequest contentRequest) { - return client.fetch(Snapshot.class, baseSnapshotName) - .flatMap(baseSnapshot -> { - determineRawAndContentPatch(snapshotToCreate, - baseSnapshot, contentRequest); - snapshotToCreate.getMetadata().setName(UUID.randomUUID().toString()); - snapshotToCreate.getSpec().setSubjectRef(contentRequest.subjectRef()); - return client.create(snapshotToCreate) - .thenReturn(snapshotToCreate); - }); - } - - private void determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot, + private Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot, ContentRequest contentRequest) { + Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); + Assert.notNull(contentRequest, "The contentRequest must not be null."); + Assert.notNull(snapshotToUse, "The snapshotToUse not be null."); String originalRaw = baseSnapshot.getSpec().getRawPatch(); String originalContent = baseSnapshot.getSpec().getContentPatch(); + String baseSnapshotName = baseSnapshot.getMetadata().getName(); + snapshotToUse.getSpec().setLastModifyTime(Instant.now()); // it is the v1 snapshot, set the content directly - if (snapshotToUse.getSpec().getVersion() == 1) { + if (StringUtils.equals(baseSnapshotName, snapshotToUse.getMetadata().getName())) { snapshotToUse.getSpec().setRawPatch(contentRequest.raw()); snapshotToUse.getSpec().setContentPatch(contentRequest.content()); + ExtensionUtil.nullSafeAnnotations(snapshotToUse) + .put(Snapshot.KEEP_RAW_ANNO, Boolean.TRUE.toString()); } else { // otherwise diff a patch based on the v1 snapshot String revisedRaw = contentRequest.rawPatchFrom(originalRaw); @@ -239,5 +150,15 @@ private void determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSn snapshotToUse.getSpec().setRawPatch(revisedRaw); snapshotToUse.getSpec().setContentPatch(revisedContent); } + return snapshotToUse; + } + + Comparator createTimeReversedComparator() { + Function name = snapshot -> snapshot.getMetadata().getName(); + Function createTime = snapshot -> snapshot.getMetadata() + .getCreationTimestamp(); + return Comparator.comparing(createTime) + .thenComparing(name) + .reversed(); } } diff --git a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java index 81957acddf..f227c1c280 100644 --- a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java +++ b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -11,6 +11,7 @@ import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.dao.OptimisticLockingFailureException; @@ -21,7 +22,9 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.content.ContentRequest; import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; import run.halo.app.content.Contributor; import run.halo.app.content.ListedPost; import run.halo.app.content.PostQuery; @@ -32,7 +35,6 @@ import run.halo.app.core.extension.Category; import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; -import run.halo.app.core.extension.Snapshot; import run.halo.app.core.extension.Tag; import run.halo.app.core.extension.User; import run.halo.app.extension.ListResult; @@ -40,8 +42,6 @@ import run.halo.app.extension.Ref; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; -import run.halo.app.infra.exception.NotFoundException; -import run.halo.app.infra.utils.JsonUtils; import run.halo.app.metrics.CounterService; import run.halo.app.metrics.MeterUtils; @@ -53,18 +53,12 @@ */ @Slf4j @Component +@AllArgsConstructor public class PostServiceImpl implements PostService { private final ContentService contentService; private final ReactiveExtensionClient client; private final CounterService counterService; - public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client, - CounterService counterService) { - this.contentService = contentService; - this.client = client; - this.counterService = counterService; - } - @Override public Mono> listPost(PostQuery query) { Comparator comparator = @@ -239,31 +233,79 @@ private Flux listContributors(List usernames) { @Override public Mono draftPost(PostRequest postRequest) { - return contentService.draftContent(postRequest.contentRequest()) - .flatMap(contentWrapper -> getContextUsername() - .flatMap(username -> { + return Mono.defer( + () -> { Post post = postRequest.post(); - post.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); - post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); - post.getSpec().setOwner(username); - appendPublishedCondition(post, Post.PostPhase.DRAFT); - return client.create(post) - .then(Mono.defer(() -> - client.fetch(Post.class, postRequest.post().getMetadata().getName()))); - })); + return getContextUsername() + .map(username -> { + post.getSpec().setOwner(username); + return post; + }) + .defaultIfEmpty(post); + } + ) + .flatMap(client::create) + .flatMap(post -> { + var contentRequest = + new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(), + postRequest.content().raw(), postRequest.content().content(), + postRequest.content().rawType()); + return contentService.draftContent(contentRequest) + .flatMap(contentWrapper -> waitForPostToDraftConcludingWork( + post.getMetadata().getName(), + contentWrapper) + ); + }) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); + } + + private Mono waitForPostToDraftConcludingWork(String postName, + ContentWrapper contentWrapper) { + return client.fetch(Post.class, postName) + .flatMap(post -> { + post.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); + post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); + if (Objects.equals(true, post.getSpec().getPublish())) { + post.getSpec().setReleaseSnapshot(post.getSpec().getHeadSnapshot()); + } + Condition condition = Condition.builder() + .type(Post.PostPhase.DRAFT.name()) + .reason("DraftedSuccessfully") + .message("Drafted post successfully.") + .status(ConditionStatus.TRUE) + .lastTransitionTime(Instant.now()) + .build(); + Post.PostStatus status = post.getStatusOrDefault(); + status.setPhase(Post.PostPhase.DRAFT.name()); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + return client.update(post); + }) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance)); } @Override public Mono updatePost(PostRequest postRequest) { Post post = postRequest.post(); + String headSnapshot = post.getSpec().getHeadSnapshot(); + String releaseSnapshot = post.getSpec().getReleaseSnapshot(); + + if (StringUtils.equals(releaseSnapshot, headSnapshot)) { + // create new snapshot to update first + return contentService.draftContent(postRequest.contentRequest(), headSnapshot) + .flatMap(contentWrapper -> { + post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); + return client.update(post); + }); + } return contentService.updateContent(postRequest.contentRequest()) .flatMap(contentWrapper -> { post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(post); }) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) - .filter(throwable -> throwable instanceof OptimisticLockingFailureException)) - .then(Mono.defer(() -> client.fetch(Post.class, post.getMetadata().getName()))); + .filter(throwable -> throwable instanceof OptimisticLockingFailureException)); } private Mono getContextUsername() { @@ -271,66 +313,4 @@ private Mono getContextUsername() { .map(SecurityContext::getAuthentication) .map(Principal::getName); } - - @Override - public Mono publishPost(String postName) { - return client.fetch(Post.class, postName) - .filter(post -> Objects.equals(true, post.getSpec().getPublish())) - .flatMap(post -> { - final Post oldPost = JsonUtils.deepCopy(post); - final Post.PostSpec postSpec = post.getSpec(); - // if it's published state but releaseSnapshot is null, it means that need to - // publish headSnapshot - // if releaseSnapshot is draft and publish state is true, it means that need to - // publish releaseSnapshot - if (StringUtils.isBlank(postSpec.getHeadSnapshot())) { - postSpec.setHeadSnapshot(postSpec.getBaseSnapshot()); - } - - if (StringUtils.isBlank(postSpec.getReleaseSnapshot())) { - postSpec.setReleaseSnapshot(postSpec.getHeadSnapshot()); - postSpec.setVersion(0); - } - return client.fetch(Snapshot.class, postSpec.getReleaseSnapshot()) - .flatMap(releasedSnapshot -> { - Ref ref = Ref.of(post); - // not published state, need to publish - return contentService.publish(releasedSnapshot.getMetadata().getName(), - ref) - .flatMap(contentWrapper -> { - appendPublishedCondition(post, Post.PostPhase.PUBLISHED); - postSpec.setVersion(contentWrapper.getVersion()); - Post.changePublishedState(post, true); - if (postSpec.getPublishTime() == null) { - postSpec.setPublishTime(Instant.now()); - } - if (!oldPost.equals(post)) { - return client.update(post); - } - return Mono.just(post); - }); - }) - .switchIfEmpty(Mono.defer(() -> Mono.error(new NotFoundException( - String.format("Snapshot [%s] not found", postSpec.getReleaseSnapshot())))) - ); - }); - } - - void appendPublishedCondition(Post post, Post.PostPhase phase) { - Assert.notNull(post, "The post must not be null."); - Post.PostStatus status = post.getStatusOrDefault(); - status.setPhase(phase.name()); - List conditions = status.getConditionsOrDefault(); - conditions.add(createCondition(phase)); - } - - Condition createCondition(Post.PostPhase phase) { - Condition condition = new Condition(); - condition.setType(phase.name()); - condition.setReason(phase.name()); - condition.setMessage(""); - condition.setStatus(ConditionStatus.TRUE); - condition.setLastTransitionTime(Instant.now()); - return condition; - } } diff --git a/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java index ba6b24943e..32b830de20 100644 --- a/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java +++ b/src/main/java/run/halo/app/content/impl/SinglePageServiceImpl.java @@ -11,8 +11,10 @@ import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationContext; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.security.core.context.ReactiveSecurityContextHolder; import org.springframework.security.core.context.SecurityContext; @@ -21,7 +23,9 @@ import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; +import run.halo.app.content.ContentRequest; import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; import run.halo.app.content.Contributor; import run.halo.app.content.ListedSinglePage; import run.halo.app.content.SinglePageQuery; @@ -32,15 +36,12 @@ import run.halo.app.core.extension.Counter; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.SinglePage; -import run.halo.app.core.extension.Snapshot; import run.halo.app.core.extension.User; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; -import run.halo.app.infra.exception.NotFoundException; -import run.halo.app.infra.utils.JsonUtils; import run.halo.app.metrics.CounterService; import run.halo.app.metrics.MeterUtils; @@ -52,6 +53,7 @@ */ @Slf4j @Service +@AllArgsConstructor public class SinglePageServiceImpl implements SinglePageService { private final ContentService contentService; @@ -59,12 +61,7 @@ public class SinglePageServiceImpl implements SinglePageService { private final CounterService counterService; - public SinglePageServiceImpl(ContentService contentService, ReactiveExtensionClient client, - CounterService counterService) { - this.contentService = contentService; - this.client = client; - this.counterService = counterService; - } + private final ApplicationContext applicationContext; @Override public Mono> list(SinglePageQuery query) { @@ -86,77 +83,80 @@ public Mono> list(SinglePageQuery query) { @Override public Mono draft(SinglePageRequest pageRequest) { - return contentService.draftContent(pageRequest.contentRequest()) - .flatMap(contentWrapper -> getContextUsername() - .flatMap(username -> { + return Mono.defer( + () -> { SinglePage page = pageRequest.page(); - page.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); - page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); - page.getSpec().setOwner(username); - appendPublishedCondition(page, Post.PostPhase.DRAFT); - return client.create(page) - .then(Mono.defer(() -> - client.fetch(SinglePage.class, - pageRequest.page().getMetadata().getName()))); - })); + return getContextUsername() + .map(username -> { + page.getSpec().setOwner(username); + return page; + }) + .defaultIfEmpty(page); + } + ) + .flatMap(client::create) + .flatMap(page -> { + var contentRequest = + new ContentRequest(Ref.of(page), page.getSpec().getHeadSnapshot(), + pageRequest.content().raw(), pageRequest.content().content(), + pageRequest.content().rawType()); + return contentService.draftContent(contentRequest) + .flatMap( + contentWrapper -> waitForPageToDraftConcludingWork( + page.getMetadata().getName(), + contentWrapper + ) + ); + }); + } + + private Mono waitForPageToDraftConcludingWork(String pageName, + ContentWrapper contentWrapper) { + return client.fetch(SinglePage.class, pageName) + .flatMap(page -> { + page.getSpec().setBaseSnapshot(contentWrapper.getSnapshotName()); + page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); + if (Objects.equals(true, page.getSpec().getPublish())) { + page.getSpec().setReleaseSnapshot(page.getSpec().getHeadSnapshot()); + } + Condition condition = Condition.builder() + .type(Post.PostPhase.DRAFT.name()) + .reason("DraftedSuccessfully") + .message("Drafted page successfully") + .status(ConditionStatus.TRUE) + .lastTransitionTime(Instant.now()) + .build(); + SinglePage.SinglePageStatus status = page.getStatusOrDefault(); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + status.setPhase(Post.PostPhase.DRAFT.name()); + return client.update(page); + }) + .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) + .filter(OptimisticLockingFailureException.class::isInstance) + ); } @Override public Mono update(SinglePageRequest pageRequest) { SinglePage page = pageRequest.page(); + String headSnapshot = page.getSpec().getHeadSnapshot(); + String releaseSnapshot = page.getSpec().getReleaseSnapshot(); + + // create new snapshot to update first + if (StringUtils.equals(headSnapshot, releaseSnapshot)) { + return contentService.draftContent(pageRequest.contentRequest(), headSnapshot) + .flatMap(contentWrapper -> { + page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); + return client.update(page); + }); + } return contentService.updateContent(pageRequest.contentRequest()) .flatMap(contentWrapper -> { page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName()); return client.update(page); }) .retryWhen(Retry.backoff(5, Duration.ofMillis(100)) - .filter(throwable -> throwable instanceof OptimisticLockingFailureException)) - .then(Mono.defer(() -> client.fetch(SinglePage.class, page.getMetadata().getName()))); - } - - @Override - public Mono publish(String name) { - return client.fetch(SinglePage.class, name) - .filter(page -> Objects.equals(true, page.getSpec().getPublish())) - .flatMap(page -> { - final SinglePage oldPage = JsonUtils.deepCopy(page); - final SinglePage.SinglePageSpec spec = page.getSpec(); - // if it's published state but releaseSnapshot is null, it means that need to - // publish headSnapshot - // if releaseSnapshot is draft and publish state is true, it means that need to - // publish releaseSnapshot - if (StringUtils.isBlank(spec.getHeadSnapshot())) { - spec.setHeadSnapshot(spec.getBaseSnapshot()); - } - - if (StringUtils.isBlank(spec.getReleaseSnapshot())) { - spec.setReleaseSnapshot(spec.getHeadSnapshot()); - // first-time to publish reset version to 0 - spec.setVersion(0); - } - return client.fetch(Snapshot.class, spec.getReleaseSnapshot()) - .flatMap(releasedSnapshot -> { - Ref ref = Ref.of(page); - // not published state, need to publish - return contentService.publish(releasedSnapshot.getMetadata().getName(), - ref) - .flatMap(contentWrapper -> { - appendPublishedCondition(page, Post.PostPhase.PUBLISHED); - spec.setVersion(contentWrapper.getVersion()); - SinglePage.changePublishedState(page, true); - if (spec.getPublishTime() == null) { - spec.setPublishTime(Instant.now()); - } - if (!oldPage.equals(page)) { - return client.update(page); - } - return Mono.just(page); - }); - }) - .switchIfEmpty(Mono.defer(() -> Mono.error(new NotFoundException( - String.format("Snapshot [%s] not found", spec.getReleaseSnapshot())))) - ); - }); + .filter(throwable -> throwable instanceof OptimisticLockingFailureException)); } private Mono getContextUsername() { @@ -285,23 +285,4 @@ boolean contains(Collection left, List right) { } return right.stream().anyMatch(left::contains); } - - void appendPublishedCondition(SinglePage page, Post.PostPhase phase) { - Assert.notNull(page, "The singlePage must not be null."); - SinglePage.SinglePageStatus status = page.getStatusOrDefault(); - status.setPhase(phase.name()); - List conditions = status.getConditionsOrDefault(); - Condition condition = new Condition(); - conditions.add(createCondition(phase)); - } - - Condition createCondition(Post.PostPhase phase) { - Condition condition = new Condition(); - condition.setType(phase.name()); - condition.setReason(phase.name()); - condition.setMessage(""); - condition.setStatus(ConditionStatus.TRUE); - condition.setLastTransitionTime(Instant.now()); - return condition; - } } diff --git a/src/main/java/run/halo/app/core/extension/Post.java b/src/main/java/run/halo/app/core/extension/Post.java index 59346b7cff..142b2ca457 100644 --- a/src/main/java/run/halo/app/core/extension/Post.java +++ b/src/main/java/run/halo/app/core/extension/Post.java @@ -5,7 +5,6 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; -import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -16,7 +15,7 @@ import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GVK; import run.halo.app.extension.MetadataOperator; -import run.halo.app.infra.Condition; +import run.halo.app.infra.ConditionList; /** *

Post extension.

@@ -33,6 +32,8 @@ public class Post extends AbstractExtension { public static final String KIND = "Post"; public static final String CATEGORIES_ANNO = "content.halo.run/categories"; + public static final String LAST_RELEASED_SNAPSHOT_ANNO = + "content.halo.run/last-released-snapshot"; public static final String TAGS_ANNO = "content.halo.run/tags"; public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String PUBLISHED_LABEL = "content.halo.run/published"; @@ -113,9 +114,6 @@ public static class PostSpec { @Schema(required = true, defaultValue = "PUBLIC") private VisibleEnum visible; - @Schema(required = true, defaultValue = "1") - private Integer version; - @Schema(required = true, defaultValue = "0") private Integer priority; @@ -135,7 +133,7 @@ public static class PostStatus { private String phase; @Schema - private List conditions; + private ConditionList conditions; private String permalink; @@ -147,12 +145,10 @@ public static class PostStatus { private List contributors; - private List releasedSnapshots; - @JsonIgnore - public List getConditionsOrDefault() { + public ConditionList getConditionsOrDefault() { if (this.conditions == null) { - this.conditions = new ArrayList<>(); + this.conditions = new ConditionList(); } return conditions; } diff --git a/src/main/java/run/halo/app/core/extension/SinglePage.java b/src/main/java/run/halo/app/core/extension/SinglePage.java index 09b318c7c6..a9f9994cad 100644 --- a/src/main/java/run/halo/app/core/extension/SinglePage.java +++ b/src/main/java/run/halo/app/core/extension/SinglePage.java @@ -27,6 +27,8 @@ public class SinglePage extends AbstractExtension { public static final String KIND = "SinglePage"; public static final String DELETED_LABEL = "content.halo.run/deleted"; public static final String PUBLISHED_LABEL = "content.halo.run/published"; + public static final String LAST_RELEASED_SNAPSHOT_ANNO = + "content.halo.run/last-released-snapshot"; public static final String OWNER_LABEL = "content.halo.run/owner"; public static final String VISIBLE_LABEL = "content.halo.run/visible"; @@ -90,9 +92,6 @@ public static class SinglePageSpec { @Schema(required = true, defaultValue = "PUBLIC") private Post.VisibleEnum visible; - @Schema(required = true, defaultValue = "1") - private Integer version; - @Schema(required = true, defaultValue = "0") private Integer priority; diff --git a/src/main/java/run/halo/app/core/extension/Snapshot.java b/src/main/java/run/halo/app/core/extension/Snapshot.java index 6dcf67be12..424f22833f 100644 --- a/src/main/java/run/halo/app/core/extension/Snapshot.java +++ b/src/main/java/run/halo/app/core/extension/Snapshot.java @@ -4,11 +4,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.LinkedHashSet; -import java.util.Map; import java.util.Set; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; import run.halo.app.content.ContentWrapper; import run.halo.app.content.PatchUtils; @@ -28,7 +28,7 @@ @EqualsAndHashCode(callSuper = true) public class Snapshot extends AbstractExtension { public static final String KIND = "Snapshot"; - public static final String PUBLISHED_LABEL = "content.halo.run/published"; + public static final String KEEP_RAW_ANNO = "content.halo.run/keep-raw"; @Schema(required = true) private SnapShotSpec spec; @@ -51,50 +51,31 @@ public static class SnapShotSpec { private String parentSnapshotName; - @Schema(required = true) - private String displayVersion; - - @Schema(required = true, defaultValue = "1") - private Integer version; + private Instant lastModifyTime; - private Instant publishTime; + @Schema(required = true, minLength = 1) + private String owner; private Set contributors; - - @JsonIgnore - public Set getContributorsOrDefault() { - if (this.contributors == null) { - this.contributors = new LinkedHashSet<>(); - } - return this.contributors; - } } - public static String displayVersionFrom(Integer version) { - Assert.notNull(version, "The version must not be null"); - return "v" + version; - } - - @JsonIgnore - public boolean isPublished() { - Map labels = getMetadata().getLabels(); - return labels != null && labels.getOrDefault(PUBLISHED_LABEL, "false").equals("true"); - } - - @JsonIgnore - public void addContributor(String name) { + public static void addContributor(Snapshot snapshot, String name) { Assert.notNull(name, "The username must not be null."); - Set contributors = spec.getContributorsOrDefault(); + Set contributors = snapshot.getSpec().getContributors(); + if (contributors == null) { + contributors = new LinkedHashSet<>(); + snapshot.getSpec().setContributors(contributors); + } contributors.add(name); } @JsonIgnore public ContentWrapper applyPatch(Snapshot baseSnapshot) { Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); - if (this.spec.version == 1) { + String baseSnapshotName = baseSnapshot.getMetadata().getName(); + if (StringUtils.equals(getMetadata().getName(), baseSnapshotName)) { return ContentWrapper.builder() .snapshotName(this.getMetadata().getName()) - .version(this.spec.version) .raw(this.spec.rawPatch) .content(this.spec.contentPatch) .rawType(this.spec.rawType) @@ -106,15 +87,9 @@ public ContentWrapper applyPatch(Snapshot baseSnapshot) { PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(), this.spec.rawPatch); return ContentWrapper.builder() .snapshotName(this.getMetadata().getName()) - .version(this.spec.version) .raw(patchedRaw) .content(patchedContent) .rawType(this.spec.rawType) .build(); } - - public static void putPublishedLabel(Map labels) { - Assert.notNull(labels, "The labels must not be null."); - labels.put(PUBLISHED_LABEL, "true"); - } } diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java index 46133025e0..037150d2d2 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -8,15 +8,18 @@ import io.swagger.v3.oas.annotations.enums.ParameterIn; import java.time.Duration; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.http.MediaType; +import org.springframework.retry.RetryException; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; +import org.thymeleaf.util.StringUtils; import reactor.core.publisher.Mono; import reactor.util.retry.Retry; import run.halo.app.content.ListedPost; @@ -24,9 +27,9 @@ import run.halo.app.content.PostRequest; import run.halo.app.content.PostService; import run.halo.app.core.extension.Post; -import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.event.post.PostRecycledEvent; import run.halo.app.event.post.PostUnpublishedEvent; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.QueryParamBuildUtil; @@ -37,6 +40,7 @@ * @author guqing * @since 2.0.0 */ +@Slf4j @Component @AllArgsConstructor public class PostEndpoint implements CustomEndpoint { @@ -92,6 +96,24 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementation(Post.class)) ) + .PUT("posts/{name}/content", this::updateContent, + builder -> builder.operationId("UpdatePostContent") + .description("Update a post's content.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(PostRequest.Content.class)) + )) + .response(responseBuilder() + .implementation(Post.class)) + ) .PUT("posts/{name}/publish", this::publishPost, builder -> builder.operationId("PublishPost") .description("Publish a post.") @@ -132,6 +154,18 @@ Mono draftPost(ServerRequest request) { .flatMap(post -> ServerResponse.ok().bodyValue(post)); } + Mono updateContent(ServerRequest request) { + String postName = request.pathVariable("name"); + return request.bodyToMono(PostRequest.Content.class) + .flatMap(content -> client.fetch(Post.class, postName) + .flatMap(post -> { + PostRequest postRequest = new PostRequest(post, content); + return postService.updatePost(postRequest); + }) + ) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + Mono updatePost(ServerRequest request) { return request.bodyToMono(PostRequest.class) .flatMap(postService::updatePost) @@ -140,22 +174,46 @@ Mono updatePost(ServerRequest request) { Mono publishPost(ServerRequest request) { var name = request.pathVariable("name"); + boolean asyncPublish = request.queryParam("async") + .map(Boolean::parseBoolean) + .orElse(false); return client.get(Post.class, name) .doOnNext(post -> { var spec = post.getSpec(); request.queryParam("headSnapshot").ifPresent(spec::setHeadSnapshot); spec.setPublish(true); + if (spec.getHeadSnapshot() == null) { + spec.setHeadSnapshot(spec.getBaseSnapshot()); + } // TODO Provide release snapshot query param to control spec.setReleaseSnapshot(spec.getHeadSnapshot()); }) .flatMap(client::update) .retryWhen(Retry.backoff(3, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) - .flatMap(post -> postService.publishPost(post.getMetadata().getName())) - // TODO Fire published event in reconciler in the future - .doOnNext(post -> eventPublisher.publishEvent( - new PostPublishedEvent(this, post.getMetadata().getName()))) - .flatMap(post -> ServerResponse.ok().bodyValue(post)); + .flatMap(post -> { + if (asyncPublish) { + return Mono.just(post); + } + return client.fetch(Post.class, name) + .map(latest -> { + String latestReleasedSnapshotName = + ExtensionUtil.nullSafeAnnotations(latest) + .get(Post.LAST_RELEASED_SNAPSHOT_ANNO); + if (StringUtils.equals(latestReleasedSnapshotName, + latest.getSpec().getReleaseSnapshot())) { + return latest; + } + throw new RetryException("Post publishing status is not as expected"); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)) + .doOnError(IllegalStateException.class, err -> { + log.error("Failed to publish post [{}]", name, err); + throw new IllegalStateException("Publishing wait timeout."); + }); + }) + .flatMap(publishResult -> ServerResponse.ok().bodyValue(publishResult)); } private Mono unpublishPost(ServerRequest request) { diff --git a/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java index fa1e4c8447..aacfc3eb0b 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/SinglePageEndpoint.java @@ -6,20 +6,27 @@ import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; import io.swagger.v3.oas.annotations.enums.ParameterIn; +import java.time.Duration; import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springdoc.core.fn.builders.schema.Builder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.http.MediaType; +import org.springframework.retry.RetryException; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; +import org.thymeleaf.util.StringUtils; import reactor.core.publisher.Mono; +import reactor.util.retry.Retry; import run.halo.app.content.ListedSinglePage; import run.halo.app.content.SinglePageQuery; import run.halo.app.content.SinglePageRequest; import run.halo.app.content.SinglePageService; +import run.halo.app.core.extension.Post; import run.halo.app.core.extension.SinglePage; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ListResult; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.QueryParamBuildUtil; @@ -30,6 +37,7 @@ * @author guqing * @since 2.0.0 */ +@Slf4j @Component @AllArgsConstructor public class SinglePageEndpoint implements CustomEndpoint { @@ -83,6 +91,24 @@ public RouterFunction endpoint() { .response(responseBuilder() .implementation(SinglePage.class)) ) + .PUT("singlepages/{name}/content", this::updateContent, + builder -> builder.operationId("UpdateSinglePageContent") + .description("Update a single page's content.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(SinglePageRequest.Content.class)) + )) + .response(responseBuilder() + .implementation(Post.class)) + ) .PUT("singlepages/{name}/publish", this::publishSinglePage, builder -> builder.operationId("PublishSinglePage") .description("Publish a single page.") @@ -103,6 +129,18 @@ Mono draftSinglePage(ServerRequest request) { .flatMap(singlePage -> ServerResponse.ok().bodyValue(singlePage)); } + Mono updateContent(ServerRequest request) { + String pageName = request.pathVariable("name"); + return request.bodyToMono(SinglePageRequest.Content.class) + .flatMap(content -> client.fetch(SinglePage.class, pageName) + .flatMap(page -> { + SinglePageRequest pageRequest = new SinglePageRequest(page, content); + return singlePageService.update(pageRequest); + }) + ) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + Mono updateSinglePage(ServerRequest request) { return request.bodyToMono(SinglePageRequest.class) .flatMap(singlePageService::update) @@ -111,14 +149,41 @@ Mono updateSinglePage(ServerRequest request) { Mono publishSinglePage(ServerRequest request) { String name = request.pathVariable("name"); + boolean asyncPublish = request.queryParam("async") + .map(Boolean::parseBoolean) + .orElse(false); return client.fetch(SinglePage.class, name) .flatMap(singlePage -> { SinglePage.SinglePageSpec spec = singlePage.getSpec(); spec.setPublish(true); + if (spec.getHeadSnapshot() == null) { + spec.setHeadSnapshot(spec.getBaseSnapshot()); + } spec.setReleaseSnapshot(spec.getHeadSnapshot()); return client.update(singlePage); }) - .flatMap(singlePage -> singlePageService.publish(singlePage.getMetadata().getName())) + .flatMap(post -> { + if (asyncPublish) { + return Mono.just(post); + } + return client.fetch(SinglePage.class, name) + .map(latest -> { + String latestReleasedSnapshotName = + ExtensionUtil.nullSafeAnnotations(latest) + .get(Post.LAST_RELEASED_SNAPSHOT_ANNO); + if (StringUtils.equals(latestReleasedSnapshotName, + latest.getSpec().getReleaseSnapshot())) { + return latest; + } + throw new RetryException("SinglePage publishing status is not as expected"); + }) + .retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException)) + .doOnError(IllegalStateException.class, err -> { + log.error("Failed to publish single page [{}]", name, err); + throw new IllegalStateException("Publishing wait timeout."); + }); + }) .flatMap(page -> ServerResponse.ok().bodyValue(page)); } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java index ad11e9f20c..e79ecea72a 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -1,14 +1,15 @@ package run.halo.app.core.extension.reconciler; import java.time.Instant; -import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import lombok.AllArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; +import org.springframework.context.ApplicationContext; import org.springframework.util.Assert; import run.halo.app.content.ContentService; import run.halo.app.content.PostService; @@ -16,12 +17,15 @@ import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Snapshot; +import run.halo.app.event.post.PostPublishedEvent; +import run.halo.app.event.post.PostUnpublishedEvent; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.ExtensionOperator; import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.Condition; +import run.halo.app.infra.ConditionList; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.utils.HaloUtils; import run.halo.app.infra.utils.JsonUtils; @@ -40,6 +44,7 @@ * @author guqing * @since 2.0.0 */ +@AllArgsConstructor public class PostReconciler implements Reconciler { private static final String FINALIZER_NAME = "post-protection"; private final ExtensionClient client; @@ -48,15 +53,7 @@ public class PostReconciler implements Reconciler { private final PostPermalinkPolicy postPermalinkPolicy; private final CounterService counterService; - public PostReconciler(ExtensionClient client, ContentService contentService, - PostService postService, PostPermalinkPolicy postPermalinkPolicy, - CounterService counterService) { - this.client = client; - this.contentService = contentService; - this.postService = postService; - this.postPermalinkPolicy = postPermalinkPolicy; - this.counterService = counterService; - } + private final ApplicationContext applicationContext; @Override public Result reconcile(Request request) { @@ -77,18 +74,87 @@ public Result reconcile(Request request) { } private void reconcileSpec(String name) { - // publish post if necessary - try { - postService.publishPost(name).block(); - } catch (Throwable e) { - publishFailed(name, e); - throw e; - } - client.fetch(Post.class, name).ifPresent(post -> { - Post oldPost = JsonUtils.deepCopy(post); - if (post.isPublished() && Objects.equals(false, post.getSpec().getPublish())) { + // un-publish post if necessary + if (post.isPublished() + && Objects.equals(false, post.getSpec().getPublish())) { + boolean success = unPublishReconcile(name); + if (success) { + applicationContext.publishEvent(new PostUnpublishedEvent(this, name)); + } + return; + } + + try { + publishPost(name); + } catch (Throwable e) { + publishFailed(name, e); + throw e; + } + }); + } + + private void publishPost(String name) { + client.fetch(Post.class, name) + .filter(post -> Objects.equals(true, post.getSpec().getPublish())) + .ifPresent(post -> { + Map annotations = ExtensionUtil.nullSafeAnnotations(post); + String lastReleasedSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO); + String releaseSnapshot = post.getSpec().getReleaseSnapshot(); + if (StringUtils.isBlank(releaseSnapshot)) { + return; + } + // do nothing if release snapshot is not changed + if (StringUtils.equals(lastReleasedSnapshot, releaseSnapshot)) { + return; + } + Post.PostStatus status = post.getStatusOrDefault(); + + // validate release snapshot + boolean present = client.fetch(Snapshot.class, releaseSnapshot) + .isPresent(); + if (!present) { + Condition condition = Condition.builder() + .type(Post.PostPhase.FAILED.name()) + .reason("SnapshotNotFound") + .message( + String.format("Snapshot [%s] not found for publish", releaseSnapshot)) + .status(ConditionStatus.FALSE) + .lastTransitionTime(Instant.now()) + .build(); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + status.setPhase(Post.PostPhase.FAILED.name()); + client.update(post); + return; + } + // do publish + annotations.put(Post.LAST_RELEASED_SNAPSHOT_ANNO, releaseSnapshot); + status.setPhase(Post.PostPhase.PUBLISHED.name()); + Condition condition = Condition.builder() + .type(Post.PostPhase.PUBLISHED.name()) + .reason("Published") + .message("Post published successfully.") + .lastTransitionTime(Instant.now()) + .status(ConditionStatus.TRUE) + .build(); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + + Post.changePublishedState(post, true); + if (post.getSpec().getPublishTime() == null) { + post.getSpec().setPublishTime(Instant.now()); + } + + client.update(post); + applicationContext.publishEvent(new PostPublishedEvent(this, name)); + }); + } + + private boolean unPublishReconcile(String name) { + return client.fetch(Post.class, name) + .map(post -> { + final Post oldPost = JsonUtils.deepCopy(post); Post.changePublishedState(post, false); + final Post.PostStatus status = post.getStatusOrDefault(); Condition condition = new Condition(); condition.setType("CancelledPublish"); @@ -96,13 +162,15 @@ private void reconcileSpec(String name) { condition.setReason(condition.getType()); condition.setMessage("CancelledPublish"); condition.setLastTransitionTime(Instant.now()); - status.getConditionsOrDefault().add(condition); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + status.setPhase(Post.PostPhase.DRAFT.name()); - } - if (!oldPost.equals(post)) { - client.update(post); - } - }); + if (!oldPost.equals(post)) { + client.update(post); + } + return true; + }) + .orElse(false); } private void publishFailed(String name, Throwable error) { @@ -115,23 +183,16 @@ private void publishFailed(String name, Throwable error) { Post.PostPhase phase = Post.PostPhase.FAILED; status.setPhase(phase.name()); - final List conditions = status.getConditionsOrDefault(); - Condition condition = new Condition(); - condition.setType(phase.name()); - condition.setReason(phase.name()); - condition.setMessage(""); - condition.setStatus(ConditionStatus.TRUE); - condition.setLastTransitionTime(Instant.now()); - condition.setMessage(error.getMessage()); - condition.setStatus(ConditionStatus.FALSE); - - if (conditions.size() > 0) { - Condition lastCondition = conditions.get(conditions.size() - 1); - if (!StringUtils.equals(lastCondition.getType(), condition.getType()) - && !StringUtils.equals(lastCondition.getMessage(), condition.getMessage())) { - conditions.add(condition); - } - } + final ConditionList conditions = status.getConditionsOrDefault(); + Condition condition = Condition.builder() + .type(phase.name()) + .reason("PublishFailed") + .message(error.getMessage()) + .status(ConditionStatus.FALSE) + .lastTransitionTime(Instant.now()) + .build(); + conditions.addAndEvictFIFO(condition); + post.setStatus(status); if (!oldPost.equals(post)) { @@ -221,24 +282,10 @@ private void reconcileStatus(String name) { status.setContributors(contributors); // update in progress status - snapshots.stream() - .filter( - snapshot -> snapshot.getMetadata().getName().equals(headSnapshot)) - .findAny() - .ifPresent(snapshot -> { - status.setInProgress(!snapshot.isPublished()); - }); - - List releasedSnapshots = snapshots.stream() - .filter(Snapshot::isPublished) - .sorted(Comparator.comparing(snapshot -> snapshot.getSpec().getVersion())) - .map(snapshot -> snapshot.getMetadata().getName()) - .toList(); - status.setReleasedSnapshots(releasedSnapshots); + status.setInProgress( + !StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot())); }); - status.setConditions(limitConditionSize(status.getConditions())); - if (!oldPost.equals(post)) { client.update(post); } @@ -298,12 +345,4 @@ private String getExcerpt(String htmlContent) { // TODO The default capture 150 words as excerpt return StringUtils.substring(text, 0, 150); } - - static List limitConditionSize(List conditions) { - if (conditions == null || conditions.size() <= 10) { - return conditions; - } - // Retain the last ten conditions - return conditions.subList(conditions.size() - 10, conditions.size()); - } } diff --git a/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java index ba7d06b44c..d02f98e145 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/SinglePageReconciler.java @@ -4,19 +4,18 @@ import static org.springframework.web.util.UriUtils.encodePath; import java.time.Instant; -import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.jsoup.Jsoup; import org.springframework.context.ApplicationContext; import org.springframework.util.Assert; import run.halo.app.content.ContentService; -import run.halo.app.content.SinglePageService; import run.halo.app.content.permalinks.ExtensionLocator; import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Post; @@ -29,6 +28,7 @@ import run.halo.app.extension.Ref; import run.halo.app.extension.controller.Reconciler; import run.halo.app.infra.Condition; +import run.halo.app.infra.ConditionList; import run.halo.app.infra.ConditionStatus; import run.halo.app.infra.ExternalUrlSupplier; import run.halo.app.infra.utils.JsonUtils; @@ -50,29 +50,17 @@ * @since 2.0.0 */ @Slf4j +@AllArgsConstructor public class SinglePageReconciler implements Reconciler { private static final String FINALIZER_NAME = "single-page-protection"; private static final GroupVersionKind GVK = GroupVersionKind.fromExtension(SinglePage.class); private final ExtensionClient client; private final ContentService contentService; private final ApplicationContext applicationContext; - private final SinglePageService singlePageService; private final CounterService counterService; private final ExternalUrlSupplier externalUrlSupplier; - public SinglePageReconciler(ExtensionClient client, ContentService contentService, - ApplicationContext applicationContext, SinglePageService singlePageService, - CounterService counterService, - ExternalUrlSupplier externalUrlSupplier) { - this.client = client; - this.contentService = contentService; - this.applicationContext = applicationContext; - this.singlePageService = singlePageService; - this.counterService = counterService; - this.externalUrlSupplier = externalUrlSupplier; - } - @Override public Result reconcile(Request request) { client.fetch(SinglePage.class, request.name()) @@ -94,28 +82,93 @@ public Result reconcile(Request request) { } private void reconcileSpec(String name) { - // publish single page if necessary - try { - singlePageService.publish(name).block(); - } catch (Throwable e) { - publishFailed(name, e); - throw e; - } - client.fetch(SinglePage.class, name).ifPresent(page -> { - SinglePage oldPage = JsonUtils.deepCopy(page); + // un-publish if necessary if (page.isPublished() && Objects.equals(false, page.getSpec().getPublish())) { - SinglePage.changePublishedState(page, false); - final SinglePage.SinglePageStatus status = page.getStatusOrDefault(); - Condition condition = new Condition(); - condition.setType("CancelledPublish"); - condition.setStatus(ConditionStatus.TRUE); - condition.setReason(condition.getType()); - condition.setMessage("CancelledPublish"); - condition.setLastTransitionTime(Instant.now()); - status.getConditionsOrDefault().add(condition); - status.setPhase(Post.PostPhase.DRAFT.name()); + unPublish(name); + return; } + + try { + publishPage(name); + } catch (Throwable e) { + publishFailed(name, e); + throw e; + } + }); + } + + private void publishPage(String name) { + client.fetch(SinglePage.class, name) + .filter(page -> Objects.equals(true, page.getSpec().getPublish())) + .ifPresent(page -> { + Map annotations = ExtensionUtil.nullSafeAnnotations(page); + String lastReleasedSnapshot = annotations.get(Post.LAST_RELEASED_SNAPSHOT_ANNO); + String releaseSnapshot = page.getSpec().getReleaseSnapshot(); + if (StringUtils.isBlank(releaseSnapshot)) { + return; + } + // do nothing if release snapshot is not changed + if (StringUtils.equals(lastReleasedSnapshot, releaseSnapshot)) { + return; + } + SinglePage.SinglePageStatus status = page.getStatusOrDefault(); + + // validate release snapshot + boolean present = client.fetch(Snapshot.class, releaseSnapshot) + .isPresent(); + if (!present) { + Condition condition = Condition.builder() + .type(Post.PostPhase.FAILED.name()) + .reason("SnapshotNotFound") + .message( + String.format("Snapshot [%s] not found for publish", releaseSnapshot)) + .status(ConditionStatus.FALSE) + .lastTransitionTime(Instant.now()) + .build(); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + status.setPhase(Post.PostPhase.FAILED.name()); + client.update(page); + return; + } + + // do publish + annotations.put(SinglePage.LAST_RELEASED_SNAPSHOT_ANNO, releaseSnapshot); + status.setPhase(Post.PostPhase.PUBLISHED.name()); + Condition condition = Condition.builder() + .type(Post.PostPhase.PUBLISHED.name()) + .reason("Published") + .message("SinglePage published successfully.") + .lastTransitionTime(Instant.now()) + .status(ConditionStatus.TRUE) + .build(); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + + SinglePage.changePublishedState(page, true); + if (page.getSpec().getPublishTime() == null) { + page.getSpec().setPublishTime(Instant.now()); + } + + client.update(page); + }); + } + + private void unPublish(String name) { + client.fetch(SinglePage.class, name).ifPresent(page -> { + final SinglePage oldPage = JsonUtils.deepCopy(page); + + SinglePage.changePublishedState(page, false); + final SinglePage.SinglePageStatus status = page.getStatusOrDefault(); + + Condition condition = new Condition(); + condition.setType("CancelledPublish"); + condition.setStatus(ConditionStatus.TRUE); + condition.setReason(condition.getType()); + condition.setMessage("CancelledPublish"); + condition.setLastTransitionTime(Instant.now()); + status.getConditionsOrDefault().addAndEvictFIFO(condition); + + status.setPhase(Post.PostPhase.DRAFT.name()); if (!oldPage.equals(page)) { client.update(page); } @@ -132,23 +185,16 @@ private void publishFailed(String name, Throwable error) { Post.PostPhase phase = Post.PostPhase.FAILED; status.setPhase(phase.name()); - final List conditions = status.getConditionsOrDefault(); - Condition condition = new Condition(); - condition.setType(phase.name()); - condition.setReason(phase.name()); - condition.setMessage(""); - condition.setStatus(ConditionStatus.TRUE); - condition.setLastTransitionTime(Instant.now()); - condition.setMessage(error.getMessage()); - condition.setStatus(ConditionStatus.FALSE); - - if (conditions.size() > 0) { - Condition lastCondition = conditions.get(conditions.size() - 1); - if (!StringUtils.equals(lastCondition.getType(), condition.getType()) - && !StringUtils.equals(lastCondition.getMessage(), condition.getMessage())) { - conditions.add(condition); - } - } + final ConditionList conditions = status.getConditionsOrDefault(); + + Condition condition = Condition.builder() + .type(phase.name()) + .reason("PublishFailed") + .message(error.getMessage()) + .lastTransitionTime(Instant.now()) + .status(ConditionStatus.FALSE) + .build(); + conditions.addAndEvictFIFO(condition); page.setStatus(status); if (!oldPage.equals(page)) { @@ -303,23 +349,10 @@ private void reconcileStatus(String name) { status.setContributors(contributors); // update in progress status - snapshots.stream() - .filter(snapshot -> snapshot.getMetadata().getName().equals(headSnapshot)) - .findAny() - .ifPresent(snapshot -> { - status.setInProgress(!snapshot.isPublished()); - }); - - List releasedSnapshots = snapshots.stream() - .filter(Snapshot::isPublished) - .sorted(Comparator.comparing(snapshot -> snapshot.getSpec().getVersion())) - .map(snapshot -> snapshot.getMetadata().getName()) - .toList(); - status.setReleasedSnapshots(releasedSnapshots); + String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot(); + status.setInProgress(!StringUtils.equals(releaseSnapshot, headSnapshot)); }); - status.setConditions(limitConditionSize(status.getConditions())); - if (!oldPage.equals(singlePage)) { client.update(singlePage); } @@ -337,12 +370,4 @@ private boolean isDeleted(SinglePage singlePage) { return Objects.equals(true, singlePage.getSpec().getDeleted()) || singlePage.getMetadata().getDeletionTimestamp() != null; } - - static List limitConditionSize(List conditions) { - if (conditions == null || conditions.size() <= 10) { - return conditions; - } - // Retain the last ten conditions - return conditions.subList(conditions.size() - 10, conditions.size()); - } } diff --git a/src/main/java/run/halo/app/extension/ExtensionUtil.java b/src/main/java/run/halo/app/extension/ExtensionUtil.java index 751b59d26f..b80ceb7aca 100644 --- a/src/main/java/run/halo/app/extension/ExtensionUtil.java +++ b/src/main/java/run/halo/app/extension/ExtensionUtil.java @@ -58,4 +58,21 @@ public static Map nullSafeLabels(AbstractExtension extension) { } return labels; } + + /** + * Gets extension metadata annotations null safe. + * + * @param extension extension must not be null + * @return extension metadata annotations + */ + public static Map nullSafeAnnotations(AbstractExtension extension) { + Assert.notNull(extension, "The extension must not be null."); + Assert.notNull(extension.getMetadata(), "The extension metadata must not be null."); + Map annotations = extension.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + extension.getMetadata().setAnnotations(annotations); + } + return annotations; + } } diff --git a/src/main/java/run/halo/app/infra/Condition.java b/src/main/java/run/halo/app/infra/Condition.java index eed971d66d..557d6c9069 100644 --- a/src/main/java/run/halo/app/infra/Condition.java +++ b/src/main/java/run/halo/app/infra/Condition.java @@ -2,7 +2,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; /** * @author guqing @@ -11,6 +14,9 @@ * @since 2.0.0 */ @Data +@Builder +@NoArgsConstructor +@AllArgsConstructor public class Condition { /** * type of condition in CamelCase or in foo.example.com/CamelCase. diff --git a/src/main/java/run/halo/app/infra/ConditionList.java b/src/main/java/run/halo/app/infra/ConditionList.java new file mode 100644 index 0000000000..60a5771918 --- /dev/null +++ b/src/main/java/run/halo/app/infra/ConditionList.java @@ -0,0 +1,116 @@ +package run.halo.app.infra; + +import java.util.AbstractCollection; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.Objects; +import java.util.function.Consumer; +import org.springframework.lang.NonNull; + +/** + *

This {@link ConditionList} to stores multiple {@link Condition}.

+ *

The element added after is always the first, the first to be removed is always the first to + * be added.

+ *

The queue head is the one whose element index is 0

+ * Note that: this class is not thread-safe. + * + * @author guqing + * @since 2.0.0 + */ +public class ConditionList extends AbstractCollection { + private static final int EVICT_THRESHOLD = 20; + private final Deque conditions = new ArrayDeque<>(); + + @Override + public boolean add(@NonNull Condition condition) { + if (isSame(conditions.peekFirst(), condition)) { + return false; + } + return conditions.add(condition); + } + + public boolean addFirst(@NonNull Condition condition) { + conditions.addFirst(condition); + return true; + } + + /** + * Add {@param #condition} and evict the first item if the size of conditions is greater than + * {@link #EVICT_THRESHOLD}. + * + * @param condition item to add + */ + public boolean addAndEvictFIFO(@NonNull Condition condition) { + return addAndEvictFIFO(condition, EVICT_THRESHOLD); + } + + /** + * Add {@param #condition} and evict the first item if the size of conditions is greater than + * {@param evictThreshold}. + * + * @param condition item to add + */ + public boolean addAndEvictFIFO(@NonNull Condition condition, int evictThreshold) { + boolean result = this.addFirst(condition); + while (conditions.size() > evictThreshold) { + removeLast(); + } + return result; + } + + public void remove(Condition condition) { + conditions.remove(condition); + } + + /** + * Retrieves, but does not remove, the head of the queue represented by + * this deque (in other words, the first element of this deque), or + * returns {@code null} if this deque is empty. + * + *

This method is equivalent to {@link #peekFirst()}. + * + * @return the head of the queue represented by this deque, or + * {@code null} if this deque is empty + */ + public Condition peek() { + return peekFirst(); + } + + public Condition peekFirst() { + return conditions.peekFirst(); + } + + public Condition removeLast() { + return conditions.removeLast(); + } + + @Override + public void clear() { + conditions.clear(); + } + + public int size() { + return conditions.size(); + } + + private boolean isSame(Condition a, Condition b) { + if (a == null || b == null) { + return false; + } + return Objects.equals(a.getType(), b.getType()) + && Objects.equals(a.getStatus(), b.getStatus()) + && Objects.equals(a.getReason(), b.getReason()) + && Objects.equals(a.getMessage(), b.getMessage()); + } + + @Override + public Iterator iterator() { + return conditions.iterator(); + } + + @Override + public void forEach(Consumer action) { + conditions.forEach(action); + } +} diff --git a/src/main/resources/extensions/role-template-post.yaml b/src/main/resources/extensions/role-template-post.yaml index 5860118de4..6810caa5d9 100644 --- a/src/main/resources/extensions/role-template-post.yaml +++ b/src/main/resources/extensions/role-template-post.yaml @@ -16,7 +16,7 @@ rules: resources: [ "posts" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "contents", "contents/publish" ] + resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content", "contents", "contents/publish" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 diff --git a/src/main/resources/extensions/role-template-singlepage.yaml b/src/main/resources/extensions/role-template-singlepage.yaml index b54b692ff6..5ab365cd54 100644 --- a/src/main/resources/extensions/role-template-singlepage.yaml +++ b/src/main/resources/extensions/role-template-singlepage.yaml @@ -15,7 +15,7 @@ rules: resources: [ "singlepages" ] verbs: [ "*" ] - apiGroups: [ "api.console.halo.run" ] - resources: [ "singlepages", "singlepages/publish", "contents", "contents/publish" ] + resources: [ "singlepages", "singlepages/publish", "singlepages/content", "contents", "contents/publish" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ] --- apiVersion: v1alpha1 diff --git a/src/test/java/run/halo/app/content/ContentRequestTest.java b/src/test/java/run/halo/app/content/ContentRequestTest.java index 3f04f1fccf..b8824ddb48 100644 --- a/src/test/java/run/halo/app/content/ContentRequestTest.java +++ b/src/test/java/run/halo/app/content/ContentRequestTest.java @@ -59,14 +59,13 @@ void toSnapshot() throws JSONException { }, "rawType": "MARKDOWN", "rawPatch": "%s", - "contentPatch": "%s", - "displayVersion": "v1", - "version": 1 + "contentPatch": "%s" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Snapshot", "metadata": { - "name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2" + "name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2", + "annotations": {} } } """.formatted(expectedRawPatch, expectedContentPath), diff --git a/src/test/java/run/halo/app/content/ContentServiceTest.java b/src/test/java/run/halo/app/content/ContentServiceTest.java index e35ada7241..a65cf613c5 100644 --- a/src/test/java/run/halo/app/content/ContentServiceTest.java +++ b/src/test/java/run/halo/app/content/ContentServiceTest.java @@ -10,8 +10,8 @@ import static run.halo.app.content.TestPost.snapshotV2; import static run.halo.app.content.TestPost.snapshotV3; -import java.time.Instant; import java.util.HashMap; +import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -25,9 +25,9 @@ import run.halo.app.content.impl.ContentServiceImpl; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.Ref; -import run.halo.app.infra.utils.JsonUtils; /** * Tests for {@link ContentService}. @@ -64,7 +64,6 @@ void draftContent() { pilingBaseSnapshot(snapshotV1); ContentWrapper contentWrapper = ContentWrapper.builder() .snapshotName("snapshot-A") - .version(1) .raw(contentRequest.raw()) .content(contentRequest.content()) .rawType(snapshotV1.getSpec().getRawType()) @@ -81,9 +80,12 @@ void draftContent() { verify(client, times(1)).create(captor.capture()); Snapshot snapshot = captor.getValue(); - snapshotV1.getMetadata().setName(snapshot.getMetadata().getName()); - snapshotV1.getSpec().setSubjectRef(ref); - assertThat(snapshot).isEqualTo(snapshotV1); + assertThat(snapshot.getMetadata().getName()) + .isNotEqualTo(snapshotV1.getMetadata().getName()); + assertThat(snapshot.getSpec().getLastModifyTime()).isNotNull(); + assertThat(snapshot.getSpec().getOwner()).isEqualTo("guqing"); + assertThat(snapshot.getSpec().getContributors()).isEqualTo(Set.of("guqing")); + assertThat(snapshot.getSpec().getSubjectRef()).isEqualTo(ref); } @Test @@ -109,7 +111,6 @@ void updateContent() { ContentWrapper contentWrapper = ContentWrapper.builder() .snapshotName(headSnapshot) - .version(1) .raw(contentRequest.raw()) .content(contentRequest.content()) .rawType(snapshotV1.getSpec().getRawType()) @@ -129,68 +130,21 @@ void updateContent() { assertThat(snapshot).isEqualTo(updated); } - @Test - void updateContentWhenHasDraftVersionButHeadPoints2Published() { - final String headSnapshot = "snapshot-A"; - Snapshot snapshotV1 = snapshotV1(); - final Ref ref = postRef("test-post"); - - Snapshot snapshotV2 = snapshotV2(); - snapshotV2.getSpec().setPublishTime(null); - - - // v1(released),v2 - snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); - pilingBaseSnapshot(snapshotV2, snapshotV1); - - when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName()))) - .thenReturn(Mono.just(snapshotV2)); - when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName()))) - .thenReturn(Mono.just(snapshotV1)); - - final ContentRequest contentRequest = - new ContentRequest(ref, headSnapshot, "C", - "

C

", snapshotV1.getSpec().getRawType()); - - when(client.create(any())).thenReturn(Mono.just(snapshotV3())); - - StepVerifier.create(contentService.latestSnapshotVersion(ref)) - .expectNext(snapshotV2) - .expectComplete() - .verify(); - - Snapshot publishedV2 = snapshotV2(); - publishedV2.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(publishedV2.getMetadata().getLabels()); - when(client.update(any())).thenReturn(Mono.just(publishedV2)); - StepVerifier.create(contentService.updateContent(contentRequest)) - .consumeNextWith(created -> { - assertThat(created.getRaw()).isEqualTo("C"); - assertThat(created.getContent()).isEqualTo("

C

"); - }) - .expectComplete() - .verify(); - } - @Test void updateContentWhenHeadPoints2Published() { final Ref ref = postRef("test-post"); // v1(released),v2 Snapshot snapshotV1 = snapshotV1(); snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); + ExtensionUtil.nullSafeAnnotations(snapshotV1) + .put(Snapshot.KEEP_RAW_ANNO, "true"); snapshotV1.getSpec().setSubjectRef(ref); Snapshot snapshotV2 = snapshotV2(); snapshotV2.getSpec().setSubjectRef(ref); - snapshotV2.getSpec().setPublishTime(null); final String headSnapshot = snapshotV2.getMetadata().getName(); - pilingBaseSnapshot(snapshotV2, snapshotV1); when(client.fetch(eq(Snapshot.class), eq(snapshotV2.getMetadata().getName()))) @@ -202,7 +156,7 @@ void updateContentWhenHeadPoints2Published() { new ContentRequest(ref, headSnapshot, "C", "

C

", snapshotV1.getSpec().getRawType()); - when(client.update(any())).thenReturn(Mono.just(snapshotV2())); + when(client.update(any())).thenReturn(Mono.just(snapshotV2)); StepVerifier.create(contentService.latestSnapshotVersion(ref)) .expectNext(snapshotV2) @@ -220,34 +174,6 @@ void updateContentWhenHeadPoints2Published() { verify(client, times(1)).update(any()); } - @Test - void publishContent() { - Ref ref = postRef("test-post"); - // v1(released),v2 - Snapshot snapshotV1 = snapshotV1(); - snapshotV1.getSpec().setPublishTime(null); - snapshotV1.getSpec().setSubjectRef(ref); - - final String headSnapshot = snapshotV1.getMetadata().getName(); - - pilingBaseSnapshot(snapshotV1); - - when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName()))) - .thenReturn(Mono.just(snapshotV1)); - - when(client.update(any())).thenReturn(Mono.just(snapshotV2())); - - StepVerifier.create(contentService.publish(headSnapshot, ref)) - .expectNext() - .consumeNextWith(p -> { - System.out.println(JsonUtils.objectToJson(p)); - }) - .expectComplete() - .verify(); - // has benn published,do nothing - verify(client, times(1)).update(any()); - } - private static Ref postRef(String name) { Ref ref = new Ref(); ref.setGroup("content.halo.run"); @@ -257,37 +183,6 @@ private static Ref postRef(String name) { return ref; } - @Test - void publishContentWhenHasPublishedThenDoNothing() { - final Ref ref = postRef("test-post"); - - // v1(released),v2 - Snapshot snapshotV1 = snapshotV1(); - snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); - snapshotV1.getSpec().setSubjectRef(ref); - - final String headSnapshot = snapshotV1.getMetadata().getName(); - - pilingBaseSnapshot(snapshotV1); - - when(client.fetch(eq(Snapshot.class), eq(snapshotV1.getMetadata().getName()))) - .thenReturn(Mono.just(snapshotV1)); - - when(client.update(any())).thenReturn(Mono.just(snapshotV2())); - - StepVerifier.create(contentService.publish(headSnapshot, ref)) - .expectNext() - .consumeNextWith(p -> { - System.out.println(JsonUtils.objectToJson(p)); - }) - .expectComplete() - .verify(); - // has benn published,do nothing - verify(client, times(0)).update(any()); - } - private void pilingBaseSnapshot(Snapshot... expected) { when(client.list(eq(Snapshot.class), any(), any())) .thenReturn(Flux.just(expected)); @@ -299,8 +194,6 @@ void baseSnapshotVersion() { final Ref ref = postRef(postName); Snapshot snapshotV1 = snapshotV1(); snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); snapshotV1.getSpec().setSubjectRef(ref); Snapshot snapshotV2 = TestPost.snapshotV2(); @@ -324,8 +217,6 @@ void latestSnapshotVersion() { final Ref ref = postRef(postName); Snapshot snapshotV1 = snapshotV1(); snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); snapshotV1.getSpec().setSubjectRef(ref); Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getSpec().setSubjectRef(ref); @@ -338,60 +229,12 @@ void latestSnapshotVersion() { .expectComplete() .verify(); + Snapshot snapshotV3 = snapshotV3(); when(client.list(eq(Snapshot.class), any(), any())) - .thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3())); + .thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3)); StepVerifier.create(contentService.latestSnapshotVersion(ref)) - .expectNext(snapshotV3()) + .expectNext(snapshotV3) .expectComplete() .verify(); } - - @Test - void latestPublishedSnapshotThenV1() { - String postName = "post-1"; - Ref ref = postRef(postName); - Snapshot snapshotV1 = snapshotV1(); - snapshotV1.getSpec().setSubjectRef(ref); - snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); - - Snapshot snapshotV2 = TestPost.snapshotV2(); - snapshotV2.getSpec().setSubjectRef(ref); - snapshotV2.getSpec().setPublishTime(null); - - when(client.list(eq(Snapshot.class), any(), any())) - .thenReturn(Flux.just(snapshotV1, snapshotV2)); - - StepVerifier.create(contentService.latestPublishedSnapshot(ref)) - .expectNext(snapshotV1) - .expectComplete() - .verify(); - } - - @Test - void latestPublishedSnapshotThenV2() { - String postName = "post-1"; - Ref ref = postRef(postName); - Snapshot snapshotV1 = snapshotV1(); - snapshotV1.getSpec().setSubjectRef(ref); - snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); - - Snapshot snapshotV2 = TestPost.snapshotV2(); - snapshotV2.getSpec().setSubjectRef(ref); - snapshotV2.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV2.getMetadata().getLabels()); - snapshotV2.getSpec().setPublishTime(Instant.now()); - - when(client.list(eq(Snapshot.class), any(), any())) - .thenReturn(Flux.just(snapshotV2, snapshotV1)); - - StepVerifier.create(contentService.latestPublishedSnapshot(ref)) - .expectNext(snapshotV2) - .expectComplete() - .verify(); - } - } \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/PostIntegrationTests.java b/src/test/java/run/halo/app/content/PostIntegrationTests.java new file mode 100644 index 0000000000..8ce99fc415 --- /dev/null +++ b/src/test/java/run/halo/app/content/PostIntegrationTests.java @@ -0,0 +1,144 @@ +package run.halo.app.content; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.csrf; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.extension.MetadataOperator; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Integration tests for {@link PostService}. + * + * @author guqing + * @since 2.0.0 + */ +@SpringBootTest +@AutoConfigureWebTestClient +@AutoConfigureTestDatabase +@WithMockUser(username = "fake-user", password = "fake-password", roles = "fake-super-role") +public class PostIntegrationTests { + + @Autowired + private WebTestClient webTestClient; + + @MockBean + RoleService roleService; + + @Autowired + ReactiveExtensionClient client; + + @BeforeEach + void setUp() { + var rule = new Role.PolicyRule.Builder() + .apiGroups("*") + .resources("*") + .verbs("*") + .build(); + var role = new Role(); + role.setRules(List.of(rule)); + when(roleService.getMonoRole("authenticated")).thenReturn(Mono.just(role)); + webTestClient = webTestClient.mutateWith(csrf()); + } + + @Test + void draftPost() { + webTestClient.post() + .uri("/apis/api.console.halo.run/v1alpha1/posts") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(postDraftRequest()) + .exchange() + .expectBody(Post.class) + .value(post -> { + MetadataOperator metadata = post.getMetadata(); + Post.PostSpec spec = post.getSpec(); + assertThat(spec.getTitle()).isEqualTo("无标题文章"); + assertThat(metadata.getCreationTimestamp()).isNotNull(); + assertThat(metadata.getName()).startsWith("post-"); + assertThat(spec.getHeadSnapshot()).isNotNull(); + assertThat(spec.getHeadSnapshot()).isEqualTo(spec.getBaseSnapshot()); + assertThat(spec.getOwner()).isEqualTo("fake-user"); + + assertThat(post.getStatus()).isNotNull(); + assertThat(post.getStatus().getPhase()).isEqualTo("DRAFT"); + assertThat(post.getStatus().getConditions().peek().getType()).isEqualTo("DRAFT"); + }); + } + + @Test + void draftPostAsPublish() { + PostRequest postRequest = postDraftRequest(); + postRequest.post().getSpec().setPublish(true); + webTestClient.post() + .uri("/apis/api.console.halo.run/v1alpha1/posts") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(postRequest) + .exchange() + .expectBody(Post.class) + .value(post -> { + assertThat(post.getSpec().getReleaseSnapshot()).isNotNull(); + assertThat(post.getSpec().getReleaseSnapshot()) + .isEqualTo(post.getSpec().getHeadSnapshot()); + assertThat(post.getSpec().getHeadSnapshot()) + .isEqualTo(post.getSpec().getBaseSnapshot()); + }); + } + + PostRequest postDraftRequest() { + String s = """ + { + "post": { + "spec": { + "title": "无标题文章", + "slug": "41c2ad39-21b4-45e4-a36b-5768245a0555", + "template": "", + "cover": "", + "deleted": false, + "publish": true, + "publishTime": "", + "pinned": false, + "allowComment": true, + "visible": "PUBLIC", + "version": 1, + "priority": 0, + "excerpt": { + "autoGenerate": true, + "raw": "" + }, + "categories": [], + "tags": [], + "htmlMetas": [] + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Post", + "metadata": { + "name": "", + "generateName": "post-" + } + }, + "content": { + "raw": "

hello world

", + "content": "

hello world

", + "rawType": "HTML" + } + } + """; + return JsonUtils.jsonToObject(s, PostRequest.class); + } +} diff --git a/src/test/java/run/halo/app/content/TestPost.java b/src/test/java/run/halo/app/content/TestPost.java index f9082ed31a..482ea69887 100644 --- a/src/test/java/run/halo/app/content/TestPost.java +++ b/src/test/java/run/halo/app/content/TestPost.java @@ -1,8 +1,10 @@ package run.halo.app.content; +import java.time.Instant; import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Snapshot; import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.GVK; import run.halo.app.extension.Metadata; @@ -23,7 +25,6 @@ public static Post postV1() { post.setSpec(postSpec); postSpec.setTitle("post-A"); - postSpec.setVersion(1); postSpec.setBaseSnapshot(snapshotV1().getMetadata().getName()); postSpec.setHeadSnapshot("base-snapshot"); postSpec.setReleaseSnapshot(null); @@ -37,13 +38,13 @@ public static Snapshot snapshotV1() { snapshot.setApiVersion(getApiVersion(Snapshot.class)); Metadata metadata = new Metadata(); metadata.setName("snapshot-A"); + metadata.setCreationTimestamp(Instant.now()); snapshot.setMetadata(metadata); + ExtensionUtil.nullSafeAnnotations(snapshot).put(Snapshot.KEEP_RAW_ANNO, "true"); Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec(); snapshot.setSpec(spec); - spec.setDisplayVersion("v1"); - spec.setVersion(1); - snapshot.addContributor("guqing"); + Snapshot.addContributor(snapshot, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch("A"); spec.setContentPatch("

A

"); @@ -56,14 +57,12 @@ public static Snapshot snapshotV2() { snapshot.setKind(Snapshot.KIND); snapshot.setApiVersion(getApiVersion(Snapshot.class)); Metadata metadata = new Metadata(); + metadata.setCreationTimestamp(Instant.now().plusSeconds(10)); metadata.setName("snapshot-B"); snapshot.setMetadata(metadata); Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec(); snapshot.setSpec(spec); - - spec.setDisplayVersion("v2"); - spec.setVersion(2); - snapshot.addContributor("guqing"); + Snapshot.addContributor(snapshot, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch(PatchUtils.diffToJsonPatch("A", "B")); spec.setContentPatch(PatchUtils.diffToJsonPatch("

A

", "

B

")); @@ -74,10 +73,9 @@ public static Snapshot snapshotV2() { public static Snapshot snapshotV3() { Snapshot snapshotV3 = snapshotV2(); snapshotV3.getMetadata().setName("snapshot-C"); + snapshotV3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(20)); Snapshot.SnapShotSpec spec = snapshotV3.getSpec(); - spec.setDisplayVersion("v3"); - spec.setVersion(3); - snapshotV3.addContributor("guqing"); + Snapshot.addContributor(snapshotV3, "guqing"); spec.setRawType("MARKDOWN"); spec.setRawPatch(PatchUtils.diffToJsonPatch("B", "C")); spec.setContentPatch(PatchUtils.diffToJsonPatch("

B

", "

C

")); diff --git a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java index 6a404774f6..63c6539957 100644 --- a/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java +++ b/src/test/java/run/halo/app/content/comment/CommentServiceImplTest.java @@ -285,8 +285,7 @@ private String expectListResultJson() { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", - "baseSnapshot": "snapshot-A", - "version": 1 + "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", @@ -330,8 +329,7 @@ private String expectListResultJson() { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", - "baseSnapshot": "snapshot-A", - "version": 1 + "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", @@ -374,8 +372,7 @@ private String expectListResultJson() { "spec": { "title": "post-A", "headSnapshot": "base-snapshot", - "baseSnapshot": "snapshot-A", - "version": 1 + "baseSnapshot": "snapshot-A" }, "apiVersion": "content.halo.run/v1alpha1", "kind": "Post", diff --git a/src/test/java/run/halo/app/content/impl/ContentServiceImplTest.java b/src/test/java/run/halo/app/content/impl/ContentServiceImplTest.java new file mode 100644 index 0000000000..c5d7ce0c93 --- /dev/null +++ b/src/test/java/run/halo/app/content/impl/ContentServiceImplTest.java @@ -0,0 +1,73 @@ +package run.halo.app.content.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.test.StepVerifier; +import run.halo.app.content.TestPost; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.Ref; + +/** + * Tests for {@link ContentServiceImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class ContentServiceImplTest { + + @Mock + private ReactiveExtensionClient client; + + @InjectMocks + private ContentServiceImpl contentService; + + @Test + void getBaseSnapshot() { + Snapshot snapshotV1 = TestPost.snapshotV1(); + ExtensionUtil.nullSafeAnnotations(snapshotV1) + .put(Snapshot.KEEP_RAW_ANNO, "true"); + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(TestPost.snapshotV2(), snapshotV1, TestPost.snapshotV3())); + contentService.getBaseSnapshot(Ref.of("fake-post")) + .as(StepVerifier::create) + .consumeNextWith( + baseSnapshot -> assertThat(baseSnapshot.getMetadata().getName()) + .isEqualTo(snapshotV1.getMetadata().getName())) + .verifyComplete(); + } + + @Test + void latestSnapshotVersion() { + Snapshot snapshotV1 = TestPost.snapshotV1(); + snapshotV1.getMetadata().setCreationTimestamp(Instant.now()); + + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV2.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(2)); + + Snapshot snapshotV3 = TestPost.snapshotV3(); + snapshotV3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(3)); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3)); + + contentService.latestSnapshotVersion(Ref.of("fake-post")) + .as(StepVerifier::create) + .consumeNextWith(s -> { + assertThat(s.getMetadata().getName()).isEqualTo(snapshotV3.getMetadata().getName()); + }) + .verifyComplete(); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java index f9aa7624b3..6efd17cd36 100644 --- a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java +++ b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java @@ -2,37 +2,21 @@ import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import java.time.Instant; -import java.util.HashMap; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.stubbing.Answer; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; -import reactor.core.publisher.Mono; -import reactor.test.StepVerifier; import run.halo.app.content.ContentService; import run.halo.app.content.PostQuery; import run.halo.app.content.TestPost; import run.halo.app.core.extension.Post; -import run.halo.app.core.extension.Snapshot; -import run.halo.app.extension.ExtensionUtil; import run.halo.app.extension.ReactiveExtensionClient; -import run.halo.app.extension.Ref; -import run.halo.app.infra.Condition; -import run.halo.app.infra.ConditionStatus; /** * Tests for {@link PostServiceImpl}. @@ -92,122 +76,7 @@ void listPredicate() { } @Test - void publishWhenPostIsNonePublishedState() { - String postName = "fake-post"; - String snapV1name = "fake-post-snapshot-v1"; - Post post = TestPost.postV1(); - post.getMetadata().setName(postName); - - // v1 not published - Snapshot snapshotV1 = TestPost.snapshotV1(); - snapshotV1.getMetadata().setName(snapV1name); - snapshotV1.getSpec().setPublishTime(null); - post.getSpec().setBaseSnapshot(snapshotV1.getMetadata().getName()); - - post.getSpec().setHeadSnapshot(null); - post.getSpec().setReleaseSnapshot(null); - when(client.fetch(eq(Post.class), eq(postName))).thenReturn(Mono.just(post)); - verify(client, times(0)).fetch(eq(Snapshot.class), eq(snapV1name)); - - postService.publishPost(postName) - .as(StepVerifier::create) - .verifyComplete(); - } - - @Test - void publishWhenPostIsPublishedStateAndNotPublishedBefore() { - String postName = "fake-post"; - String snapV1name = "fake-post-snapshot-v1"; - Post post = TestPost.postV1(); - post.getSpec().setPublish(true); - post.getSpec().setPublishTime(null); - post.getMetadata().setName(postName); - post.getSpec().setBaseSnapshot(snapV1name); - post.getSpec().setHeadSnapshot(null); - post.getSpec().setReleaseSnapshot(null); - when(client.fetch(eq(Post.class), eq(postName))).thenReturn(Mono.just(post)); - - // v1 not published - Snapshot snapshotV1 = TestPost.snapshotV1(); - snapshotV1.getMetadata().setName(snapV1name); - snapshotV1.getSpec().setPublishTime(null); - when(client.fetch(eq(Snapshot.class), eq(snapV1name))).thenReturn(Mono.just(snapshotV1)); - - when(contentService.publish(eq(snapV1name), eq(Ref.of(post)))) - .thenReturn(Mono.just(snapshotV1.applyPatch(snapshotV1))); - - when(client.update(any(Post.class))).thenAnswer((Answer>) invocation -> { - Post updated = invocation.getArgument(0); - return Mono.just(updated); - }); - - postService.publishPost(postName) - .as(StepVerifier::create) - .consumeNextWith(expected -> { - ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); - verify(client, times(1)).update(captor.capture()); - Post updated = captor.getValue(); - assertThat(updated.getSpec().getReleaseSnapshot()).isEqualTo(snapV1name); - assertThat(updated.getSpec().getHeadSnapshot()).isEqualTo(snapV1name); - assertThat(updated.getSpec().getPublishTime()).isNotNull(); - assertThat(updated.getSpec().getVersion()).isEqualTo(1); - List conditions = updated.getStatus().getConditions(); - assertThat(conditions).hasSize(1); - assertThat(conditions.get(0).getType()).isEqualTo("PUBLISHED"); - assertThat(conditions.get(0).getStatus()).isEqualTo(ConditionStatus.TRUE); - assertThat(expected).isNotNull(); - }) - .verifyComplete(); - } - - @Test - void publishWhenPostIsPublishedStateAndPublishedBefore() { - String postName = "fake-post"; - String snapV1name = "fake-post-snapshot-v1"; - String snapV2name = "fake-post-snapshot-v2"; - Post post = TestPost.postV1(); - post.getSpec().setPublish(true); - post.getSpec().setPublishTime(null); - post.getMetadata().setName(postName); - post.getSpec().setBaseSnapshot(snapV1name); - post.getSpec().setHeadSnapshot(snapV2name); - post.getSpec().setReleaseSnapshot(snapV2name); - ExtensionUtil.nullSafeLabels(post).put(Post.PUBLISHED_LABEL, "true"); - when(client.fetch(eq(Post.class), eq(postName))).thenReturn(Mono.just(post)); - - // v1 has been published - Snapshot snapshotV1 = TestPost.snapshotV1(); - snapshotV1.getMetadata().setName(snapV1name); - snapshotV1.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV1.getMetadata().getLabels()); - snapshotV1.getSpec().setPublishTime(Instant.now()); - - // v1 not published - Snapshot snapshotV2 = TestPost.snapshotV2(); - snapshotV2.getMetadata().setName(snapV2name); - snapshotV2.getSpec().setPublishTime(null); - when(client.fetch(eq(Snapshot.class), eq(snapV2name))).thenReturn(Mono.just(snapshotV2)); - - when(contentService.publish(eq(snapV2name), eq(Ref.of(post)))) - .thenReturn(Mono.just(snapshotV2.applyPatch(snapshotV1))); - - when(client.update(any(Post.class))).thenAnswer((Answer>) invocation -> { - Post updated = invocation.getArgument(0); - return Mono.just(updated); - }); + void draftPost() { - postService.publishPost(postName) - .as(StepVerifier::create) - .consumeNextWith(expected -> { - assertThat(expected).isNotNull(); - ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); - verify(client, times(1)).update(captor.capture()); - Post updated = captor.getValue(); - assertThat(updated.getSpec().getReleaseSnapshot()).isEqualTo(snapV2name); - assertThat(updated.getSpec().getHeadSnapshot()).isEqualTo(snapV2name); - assertThat(updated.getSpec().getPublishTime()).isNotNull(); - assertThat(updated.getSpec().getVersion()).isEqualTo(2); - }) - .verifyComplete(); } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java index d4686cef13..50d617944c 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java @@ -2,9 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; import org.junit.jupiter.api.BeforeEach; @@ -20,7 +17,6 @@ import run.halo.app.content.PostService; import run.halo.app.content.TestPost; import run.halo.app.core.extension.Post; -import run.halo.app.event.post.PostPublishedEvent; import run.halo.app.extension.ReactiveExtensionClient; /** @@ -79,25 +75,6 @@ void updatePost() { .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); } - @Test - void publishPost() { - Post post = TestPost.postV1(); - when(postService.publishPost(any())).thenReturn(Mono.just(post)); - when(client.get(eq(Post.class), eq(post.getMetadata().getName()))) - .thenReturn(Mono.just(post)); - when(client.update(any())).thenReturn(Mono.just(post)); - doNothing().when(eventPublisher).publishEvent(isA(PostPublishedEvent.class)); - - webTestClient.put() - .uri("/posts/post-A/publish") - .bodyValue(postRequest(TestPost.postV1())) - .exchange() - .expectStatus() - .isOk() - .expectBody(Post.class) - .value(p -> assertThat(p).isEqualTo(post)); - } - PostRequest postRequest(Post post) { return new PostRequest(post, new PostRequest.Content("B", "

B

", "MARKDOWN")); } diff --git a/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java index b52fc99776..47b2910da6 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java @@ -7,8 +7,6 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import java.time.Instant; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Optional; @@ -30,7 +28,6 @@ import run.halo.app.core.extension.Snapshot; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.controller.Reconciler; -import run.halo.app.infra.Condition; /** * Tests for {@link PostReconciler}. @@ -64,7 +61,6 @@ void reconcile() { .thenReturn(Optional.of(post)); when(contentService.getContent(eq(post.getSpec().getReleaseSnapshot()))) .thenReturn(Mono.empty()); - when(postService.publishPost(eq(name))).thenReturn(Mono.empty()); Snapshot snapshotV1 = TestPost.snapshotV1(); Snapshot snapshotV2 = TestPost.snapshotV2(); @@ -108,8 +104,6 @@ void reconcileExcerpt() { Snapshot snapshotV2 = TestPost.snapshotV2(); snapshotV2.getMetadata().setLabels(new HashMap<>()); - Snapshot.putPublishedLabel(snapshotV2.getMetadata().getLabels()); - snapshotV2.getSpec().setPublishTime(Instant.now()); snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); Snapshot snapshotV1 = TestPost.snapshotV1(); @@ -117,36 +111,12 @@ void reconcileExcerpt() { when(contentService.listSnapshots(any())) .thenReturn(Flux.just(snapshotV1, snapshotV2)); - when(postService.publishPost(eq(name))).thenReturn(Mono.empty()); ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); postReconciler.reconcile(new Reconciler.Request(name)); - verify(client, times(3)).update(captor.capture()); + verify(client, times(4)).update(captor.capture()); Post value = captor.getValue(); assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); } - - @Test - void limitConditionSize() { - List conditions = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - Condition condition = new Condition(); - condition.setType("test-" + i); - conditions.add(condition); - } - List subConditions = PostReconciler.limitConditionSize(conditions); - assertThat(subConditions.get(0).getType()).isEqualTo("test-0"); - assertThat(subConditions.get(9).getType()).isEqualTo("test-9"); - - for (int i = 10; i < 15; i++) { - Condition condition = new Condition(); - condition.setType("test-" + i); - conditions.add(condition); - } - subConditions = PostReconciler.limitConditionSize(conditions); - assertThat(subConditions.size()).isEqualTo(10); - assertThat(subConditions.get(0).getType()).isEqualTo("test-5"); - assertThat(subConditions.get(9).getType()).isEqualTo("test-14"); - } } \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java index 6aa1047c30..efee5742f6 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/SinglePageReconcilerTest.java @@ -89,7 +89,6 @@ void reconcile() { when(contentService.listSnapshots(any())) .thenReturn(Flux.just(snapshotV1, snapshotV2)); when(externalUrlSupplier.get()).thenReturn(URI.create("")); - when(singlePageService.publish(eq(name))).thenReturn(Mono.empty()); ArgumentCaptor captor = ArgumentCaptor.forClass(SinglePage.class); singlePageReconciler.reconcile(new Reconciler.Request(name)); @@ -138,7 +137,6 @@ public static SinglePage pageV1() { spec.setTitle("page-A"); spec.setSlug("page-slug"); - spec.setVersion(1); spec.setBaseSnapshot(snapshotV1().getMetadata().getName()); spec.setHeadSnapshot("base-snapshot"); spec.setReleaseSnapshot(null); diff --git a/src/test/java/run/halo/app/infra/ConditionListTest.java b/src/test/java/run/halo/app/infra/ConditionListTest.java new file mode 100644 index 0000000000..f804a2b068 --- /dev/null +++ b/src/test/java/run/halo/app/infra/ConditionListTest.java @@ -0,0 +1,154 @@ +package run.halo.app.infra; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Iterator; +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link ConditionList}. + * + * @author guqing + * @since 2.0.0 + */ +class ConditionListTest { + + @Test + void add() { + ConditionList conditionList = new ConditionList(); + conditionList.add(condition("type", "message", "reason", ConditionStatus.FALSE)); + conditionList.add(condition("type", "message", "reason", ConditionStatus.FALSE)); + + assertThat(conditionList.size()).isEqualTo(1); + conditionList.add(condition("type", "message", "reason", ConditionStatus.TRUE)); + assertThat(conditionList.size()).isEqualTo(2); + } + + @Test + void addAndEvictFIFO() throws JSONException { + ConditionList conditionList = new ConditionList(); + conditionList.addFirst(condition("type", "message", "reason", ConditionStatus.FALSE)); + conditionList.addFirst(condition("type2", "message2", "reason2", ConditionStatus.FALSE)); + conditionList.addFirst(condition("type3", "message3", "reason3", ConditionStatus.FALSE)); + + JSONAssert.assertEquals(""" + [ + { + "type": "type3", + "status": "FALSE", + "message": "message3", + "reason": "reason3" + }, + { + "type": "type2", + "status": "FALSE", + "message": "message2", + "reason": "reason2" + }, + { + "type": "type", + "status": "FALSE", + "message": "message", + "reason": "reason" + } + ] + """, + JsonUtils.objectToJson(conditionList), + true); + assertThat(conditionList.size()).isEqualTo(3); + + conditionList.addAndEvictFIFO( + condition("type4", "message4", "reason4", ConditionStatus.FALSE), 1); + + assertThat(conditionList.size()).isEqualTo(1); + + // json serialize test. + JSONAssert.assertEquals(""" + [ + { + "type": "type4", + "status": "FALSE", + "message": "message4", + "reason": "reason4" + } + ] + """, + JsonUtils.objectToJson(conditionList), true); + } + + @Test + void peek() { + ConditionList conditionList = new ConditionList(); + conditionList.addFirst(condition("type", "message", "reason", ConditionStatus.FALSE)); + Condition condition = condition("type2", "message2", "reason2", ConditionStatus.FALSE); + conditionList.addFirst(condition); + + Condition peek = conditionList.peek(); + assertThat(peek).isEqualTo(condition); + } + + @Test + void removeLast() { + ConditionList conditionList = new ConditionList(); + Condition condition = condition("type", "message", "reason", ConditionStatus.FALSE); + conditionList.addFirst(condition); + + conditionList.addFirst(condition("type2", "message2", "reason2", ConditionStatus.FALSE)); + + assertThat(conditionList.size()).isEqualTo(2); + assertThat(conditionList.removeLast()).isEqualTo(condition); + assertThat(conditionList.size()).isEqualTo(1); + } + + @Test + void test() { + ConditionList conditionList = new ConditionList(); + conditionList.addAndEvictFIFO( + condition("type", "message", "reason", ConditionStatus.FALSE)); + conditionList.addAndEvictFIFO( + condition("type2", "message2", "reason2", ConditionStatus.FALSE)); + + Iterator iterator = conditionList.iterator(); + assertThat(iterator.next().getType()).isEqualTo("type2"); + assertThat(iterator.next().getType()).isEqualTo("type"); + } + + @Test + void deserialization() { + String s = """ + [{ + "type": "type3", + "status": "FALSE", + "message": "message3", + "reason": "reason3" + }, + { + "type": "type2", + "status": "FALSE", + "message": "message2", + "reason": "reason2" + }, + { + "type": "type", + "status": "FALSE", + "message": "message", + "reason": "reason" + }] + """; + ConditionList conditions = JsonUtils.jsonToObject(s, ConditionList.class); + assertThat(conditions.peek().getType()).isEqualTo("type3"); + } + + private Condition condition(String type, String message, String reason, + ConditionStatus status) { + Condition condition = new Condition(); + condition.setType(type); + condition.setMessage(message); + condition.setReason(reason); + condition.setStatus(status); + return condition; + } +} \ No newline at end of file