From b9957542f4cbb5b2ab3b39bc3345ae758fe6fac8 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Mon, 22 Aug 2022 15:32:11 +0800 Subject: [PATCH] feat: add post module basic implementation (#2326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /milestone 2.0 /area core #### What this PR does / why we need it: 新增文章模块的基本实现 提供创建文章,更新草稿和发布文章三个 API #### Which issue(s) this PR fixes: A part of #2322 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note None ``` --- build.gradle | 6 + .../app/config/ExtensionConfiguration.java | 11 + .../run/halo/app/content/ContentRequest.java | 52 +++ .../run/halo/app/content/ContentService.java | 30 ++ .../run/halo/app/content/ContentWrapper.java | 13 + .../run/halo/app/content/Contributor.java | 16 + .../java/run/halo/app/content/ListedPost.java | 31 ++ .../java/run/halo/app/content/PatchUtils.java | 88 +++++ .../java/run/halo/app/content/PostQuery.java | 25 ++ .../run/halo/app/content/PostRequest.java | 23 ++ .../run/halo/app/content/PostService.java | 22 ++ .../app/content/impl/ContentServiceImpl.java | 235 ++++++++++++ .../app/content/impl/PostServiceImpl.java | 237 ++++++++++++ .../run/halo/app/core/extension/Category.java | 60 +++ .../run/halo/app/core/extension/Comment.java | 88 +++++ .../run/halo/app/core/extension/Post.java | 152 ++++++++ .../run/halo/app/core/extension/Reply.java | 36 ++ .../run/halo/app/core/extension/Snapshot.java | 129 +++++++ .../java/run/halo/app/core/extension/Tag.java | 65 ++++ .../extension/endpoint/ContentEndpoint.java | 135 +++++++ .../core/extension/endpoint/PostEndpoint.java | 140 +++++++ .../extension/reconciler/PostReconciler.java | 151 ++++++++ .../run/halo/app/extension/ListResult.java | 19 + .../java/run/halo/app/infra/Condition.java | 50 +++ .../run/halo/app/infra/ConditionStatus.java | 11 + .../run/halo/app/infra/SchemeInitializer.java | 12 + .../halo/app/content/ContentRequestTest.java | 135 +++++++ .../halo/app/content/ContentServiceTest.java | 363 ++++++++++++++++++ .../java/run/halo/app/content/TestPost.java | 92 +++++ .../app/content/impl/PostServiceImplTest.java | 77 ++++ .../extension/endpoint/PostEndpointTest.java | 86 +++++ .../reconciler/PostReconcilerTest.java | 81 ++++ .../halo/app/extension/ListResultTest.java | 8 + 33 files changed, 2679 insertions(+) create mode 100644 src/main/java/run/halo/app/content/ContentRequest.java create mode 100644 src/main/java/run/halo/app/content/ContentService.java create mode 100644 src/main/java/run/halo/app/content/ContentWrapper.java create mode 100644 src/main/java/run/halo/app/content/Contributor.java create mode 100644 src/main/java/run/halo/app/content/ListedPost.java create mode 100644 src/main/java/run/halo/app/content/PatchUtils.java create mode 100644 src/main/java/run/halo/app/content/PostQuery.java create mode 100644 src/main/java/run/halo/app/content/PostRequest.java create mode 100644 src/main/java/run/halo/app/content/PostService.java create mode 100644 src/main/java/run/halo/app/content/impl/ContentServiceImpl.java create mode 100644 src/main/java/run/halo/app/content/impl/PostServiceImpl.java create mode 100644 src/main/java/run/halo/app/core/extension/Category.java create mode 100644 src/main/java/run/halo/app/core/extension/Comment.java create mode 100644 src/main/java/run/halo/app/core/extension/Post.java create mode 100644 src/main/java/run/halo/app/core/extension/Reply.java create mode 100644 src/main/java/run/halo/app/core/extension/Snapshot.java create mode 100644 src/main/java/run/halo/app/core/extension/Tag.java create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/ContentEndpoint.java create mode 100644 src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java create mode 100644 src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java create mode 100644 src/main/java/run/halo/app/infra/Condition.java create mode 100644 src/main/java/run/halo/app/infra/ConditionStatus.java create mode 100644 src/test/java/run/halo/app/content/ContentRequestTest.java create mode 100644 src/test/java/run/halo/app/content/ContentServiceTest.java create mode 100644 src/test/java/run/halo/app/content/TestPost.java create mode 100644 src/test/java/run/halo/app/content/impl/PostServiceImplTest.java create mode 100644 src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java create mode 100644 src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java diff --git a/build.gradle b/build.gradle index cd1cbbbee..d2b802949 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,9 @@ ext { commonsLang3 = "3.12.0" base62 = "0.1.3" pf4j = "3.6.0" + javaDiffUtils = "4.12" + guava = "31.1-jre" + jsoup = "1.15.2" } dependencies { @@ -68,6 +71,9 @@ dependencies { implementation "org.apache.commons:commons-lang3:$commonsLang3" implementation "io.seruco.encoding:base62:$base62" implementation "org.pf4j:pf4j:$pf4j" + implementation "com.google.guava:guava:$guava" + implementation "org.jsoup:jsoup:$jsoup" + implementation "io.github.java-diff-utils:java-diff-utils:$javaDiffUtils" compileOnly 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/run/halo/app/config/ExtensionConfiguration.java b/src/main/java/run/halo/app/config/ExtensionConfiguration.java index 96a8950d2..9fcf855e4 100644 --- a/src/main/java/run/halo/app/config/ExtensionConfiguration.java +++ b/src/main/java/run/halo/app/config/ExtensionConfiguration.java @@ -5,9 +5,11 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.content.ContentService; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.Plugin; +import run.halo.app.core.extension.Post; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.Theme; @@ -15,6 +17,7 @@ import run.halo.app.core.extension.User; import run.halo.app.core.extension.reconciler.MenuItemReconciler; import run.halo.app.core.extension.reconciler.MenuReconciler; import run.halo.app.core.extension.reconciler.PluginReconciler; +import run.halo.app.core.extension.reconciler.PostReconciler; import run.halo.app.core.extension.reconciler.RoleBindingReconciler; import run.halo.app.core.extension.reconciler.RoleReconciler; import run.halo.app.core.extension.reconciler.ThemeReconciler; @@ -120,6 +123,14 @@ public class ExtensionConfiguration { .extension(new Theme()) .build(); } + + @Bean + Controller postController(ExtensionClient client, ContentService contentService) { + return new ControllerBuilder("post-controller", client) + .reconciler(new PostReconciler(client, contentService)) + .extension(new Post()) + .build(); + } } } diff --git a/src/main/java/run/halo/app/content/ContentRequest.java b/src/main/java/run/halo/app/content/ContentRequest.java new file mode 100644 index 000000000..4dfee93e9 --- /dev/null +++ b/src/main/java/run/halo/app/content/ContentRequest.java @@ -0,0 +1,52 @@ +package run.halo.app.content; + +import io.swagger.v3.oas.annotations.media.Schema; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.Metadata; + +/** + * @author guqing + * @since 2.0.0 + */ +public record ContentRequest(@Schema(required = true) Snapshot.SubjectRef subjectRef, + String headSnapshotName, + @Schema(required = true) String raw, + @Schema(required = true) String content, + @Schema(required = true) String rawType) { + + public Snapshot toSnapshot() { + Snapshot snapshot = new Snapshot(); + + Metadata metadata = new Metadata(); + metadata.setName(defaultName(subjectRef)); + snapshot.setMetadata(metadata); + + Snapshot.SnapShotSpec snapShotSpec = new Snapshot.SnapShotSpec(); + snapShotSpec.setSubjectRef(subjectRef); + snapShotSpec.setVersion(1); + snapShotSpec.setRawType(rawType); + snapShotSpec.setRawPatch(raw); + snapShotSpec.setContentPatch(content); + String displayVersion = Snapshot.displayVersionFrom(snapShotSpec.getVersion()); + snapShotSpec.setDisplayVersion(displayVersion); + + snapshot.setSpec(snapShotSpec); + return snapshot; + } + + private String defaultName(Snapshot.SubjectRef 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); + } + + public String contentPatchFrom(String originalContent) { + // originalContent from v1 + return PatchUtils.diffToJsonPatch(originalContent, this.content); + } +} diff --git a/src/main/java/run/halo/app/content/ContentService.java b/src/main/java/run/halo/app/content/ContentService.java new file mode 100644 index 000000000..93187544c --- /dev/null +++ b/src/main/java/run/halo/app/content/ContentService.java @@ -0,0 +1,30 @@ +package run.halo.app.content; + +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Snapshot; + +/** + * Content service for {@link Snapshot}. + * + * @author guqing + * @since 2.0.0 + */ +public interface ContentService { + + Mono getContent(String name); + + Mono draftContent(ContentRequest content); + + Mono updateContent(ContentRequest content); + + Mono publish(String headSnapshotName, Snapshot.SubjectRef subjectRef); + + Mono getBaseSnapshot(Snapshot.SubjectRef subjectRef); + + Mono latestSnapshotVersion(Snapshot.SubjectRef subjectRef); + + Mono latestPublishedSnapshot(Snapshot.SubjectRef subjectRef); + + Flux listSnapshots(Snapshot.SubjectRef subjectRef); +} diff --git a/src/main/java/run/halo/app/content/ContentWrapper.java b/src/main/java/run/halo/app/content/ContentWrapper.java new file mode 100644 index 000000000..d264af887 --- /dev/null +++ b/src/main/java/run/halo/app/content/ContentWrapper.java @@ -0,0 +1,13 @@ +package run.halo.app.content; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * @author guqing + * @since 2.0.0 + */ +public record ContentWrapper(@Schema(required = true) String snapshotName, + @Schema(required = true) String raw, + @Schema(required = true) String content, + @Schema(required = true) String rawType) { +} diff --git a/src/main/java/run/halo/app/content/Contributor.java b/src/main/java/run/halo/app/content/Contributor.java new file mode 100644 index 000000000..fb86fb490 --- /dev/null +++ b/src/main/java/run/halo/app/content/Contributor.java @@ -0,0 +1,16 @@ +package run.halo.app.content; + +import lombok.Data; + +/** + * Contributor from user. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class Contributor { + private String displayName; + private String avatar; + private String name; +} diff --git a/src/main/java/run/halo/app/content/ListedPost.java b/src/main/java/run/halo/app/content/ListedPost.java new file mode 100644 index 000000000..7c1323369 --- /dev/null +++ b/src/main/java/run/halo/app/content/ListedPost.java @@ -0,0 +1,31 @@ +package run.halo.app.content; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Data; +import run.halo.app.core.extension.Category; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.Tag; + +/** + * An aggregate object of {@link Post} and {@link Category} + * and {@link Tag} and more for post list. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class ListedPost { + + @Schema(required = true) + private Post post; + + @Schema(required = true) + private List categories; + + @Schema(required = true) + private List tags; + + @Schema(required = true) + private List contributors; +} diff --git a/src/main/java/run/halo/app/content/PatchUtils.java b/src/main/java/run/halo/app/content/PatchUtils.java new file mode 100644 index 000000000..1813c7516 --- /dev/null +++ b/src/main/java/run/halo/app/content/PatchUtils.java @@ -0,0 +1,88 @@ +package run.halo.app.content; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.difflib.DiffUtils; +import com.github.difflib.patch.AbstractDelta; +import com.github.difflib.patch.ChangeDelta; +import com.github.difflib.patch.Chunk; +import com.github.difflib.patch.DeleteDelta; +import com.github.difflib.patch.DeltaType; +import com.github.difflib.patch.InsertDelta; +import com.github.difflib.patch.Patch; +import com.github.difflib.patch.PatchFailedException; +import com.google.common.base.Splitter; +import java.util.Collections; +import java.util.List; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import run.halo.app.infra.utils.JsonUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +public class PatchUtils { + private static final String DELIMITER = "\n"; + private static final Splitter lineSplitter = Splitter.on(DELIMITER); + + public static Patch create(String deltasJson) { + List deltas = JsonUtils.jsonToObject(deltasJson, new TypeReference<>() { + }); + Patch patch = new Patch<>(); + for (Delta delta : deltas) { + StringChunk sourceChunk = delta.getSource(); + StringChunk targetChunk = delta.getTarget(); + Chunk orgChunk = new Chunk<>(sourceChunk.getPosition(), sourceChunk.getLines(), + sourceChunk.getChangePosition()); + Chunk revChunk = new Chunk<>(targetChunk.getPosition(), targetChunk.getLines(), + targetChunk.getChangePosition()); + switch (delta.getType()) { + case DELETE -> patch.addDelta(new DeleteDelta<>(orgChunk, revChunk)); + case INSERT -> patch.addDelta(new InsertDelta<>(orgChunk, revChunk)); + case CHANGE -> patch.addDelta(new ChangeDelta<>(orgChunk, revChunk)); + default -> throw new IllegalArgumentException("Unsupported delta type."); + } + } + return patch; + } + + public static String patchToJson(Patch patch) { + List> deltas = patch.getDeltas(); + return JsonUtils.objectToJson(deltas); + } + + public static String applyPatch(String original, String patchJson) { + Patch patch = PatchUtils.create(patchJson); + try { + return String.join(DELIMITER, patch.applyTo(breakLine(original))); + } catch (PatchFailedException e) { + throw new RuntimeException(e); + } + } + + public static String diffToJsonPatch(String original, String revised) { + Patch patch = DiffUtils.diff(breakLine(original), breakLine(revised)); + return PatchUtils.patchToJson(patch); + } + + public static List breakLine(String content) { + if (StringUtils.isBlank(content)) { + return Collections.emptyList(); + } + return lineSplitter.splitToList(content); + } + + @Data + public static class Delta { + private StringChunk source; + private StringChunk target; + private DeltaType type; + } + + @Data + public static class StringChunk { + private int position; + private List lines; + private List changePosition; + } +} diff --git a/src/main/java/run/halo/app/content/PostQuery.java b/src/main/java/run/halo/app/content/PostQuery.java new file mode 100644 index 000000000..b44aec60a --- /dev/null +++ b/src/main/java/run/halo/app/content/PostQuery.java @@ -0,0 +1,25 @@ +package run.halo.app.content; + +import java.util.Set; +import lombok.Data; +import lombok.EqualsAndHashCode; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.router.ListRequest; + +/** + * A query object for {@link Post} list. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class PostQuery extends ListRequest { + + private Set contributors; + + private Set categories; + + private Set tags; + // TODO add more query fields +} diff --git a/src/main/java/run/halo/app/content/PostRequest.java b/src/main/java/run/halo/app/content/PostRequest.java new file mode 100644 index 000000000..60fc63291 --- /dev/null +++ b/src/main/java/run/halo/app/content/PostRequest.java @@ -0,0 +1,23 @@ +package run.halo.app.content; + +import io.swagger.v3.oas.annotations.media.Schema; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.Snapshot; + +/** + * @author guqing + * @since 2.0.0 + */ +public record PostRequest(@Schema(required = true) Post post, + @Schema(required = true) Content content) { + + public ContentRequest contentRequest() { + Snapshot.SubjectRef subjectRef = + Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName()); + return new ContentRequest(subjectRef, post.getSpec().getHeadSnapshot(), content.raw, + content.content, content.rawType); + } + + public record Content(String raw, String content, String rawType) { + } +} diff --git a/src/main/java/run/halo/app/content/PostService.java b/src/main/java/run/halo/app/content/PostService.java new file mode 100644 index 000000000..95a4b560b --- /dev/null +++ b/src/main/java/run/halo/app/content/PostService.java @@ -0,0 +1,22 @@ +package run.halo.app.content; + +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.ListResult; + +/** + * Service for {@link Post}. + * + * @author guqing + * @since 2.0.0 + */ +public interface PostService { + + Mono> listPost(PostQuery query); + + Mono draftPost(PostRequest postRequest); + + Mono updatePost(PostRequest postRequest); + + Mono publishPost(String postName); +} diff --git a/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java new file mode 100644 index 000000000..d59129ac6 --- /dev/null +++ b/src/main/java/run/halo/app/content/impl/ContentServiceImpl.java @@ -0,0 +1,235 @@ +package run.halo.app.content.impl; + +import java.security.Principal; +import java.time.Instant; +import java.util.Comparator; +import java.util.UUID; +import org.apache.commons.lang3.StringUtils; +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.ContentRequest; +import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.extension.ReactiveExtensionClient; + +/** + * A default implementation of {@link ContentService}. + * + * @author guqing + * @since 2.0.0 + */ +@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) { + this.client = client; + } + + @Override + public Mono getContent(String name) { + return client.fetch(Snapshot.class, name) + .flatMap(snapshot -> getBaseSnapshot(snapshot.getSpec().getSubjectRef()) + .map(snapshot::applyPatch)); + } + + @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); + }); + } + + @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); + }) + .flatMap(snapshot -> restoredContent(snapshot) + .map(content -> new ContentWrapper(snapshot.getMetadata().getName(), + content.raw(), content.content(), content.rawType()) + ) + ); + } + + @Override + public Mono publish(String headSnapshotName, Snapshot.SubjectRef 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); + } + + 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, + Snapshot.SubjectRef 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); + } + + @Override + public Mono getBaseSnapshot(Snapshot.SubjectRef subjectRef) { + return listSnapshots(subjectRef) + .filter(snapshot -> snapshot.getSpec().getVersion() == 1) + .next(); + } + + private Mono handleSnapshot(Snapshot headSnapshot, ContentRequest contentRequest, + String username) { + Snapshot.SubjectRef 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)) { + // 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(Snapshot.SubjectRef subjectRef) { + Assert.notNull(subjectRef, "The subjectRef must not be null."); + return listSnapshots(subjectRef) + .sort(LATEST_SNAPSHOT_COMPARATOR) + .next(); + } + + @Override + public Mono latestPublishedSnapshot(Snapshot.SubjectRef subjectRef) { + Assert.notNull(subjectRef, "The subjectRef must not be null."); + return listSnapshots(subjectRef) + .filter(Snapshot::isPublished) + .sort(LATEST_SNAPSHOT_COMPARATOR) + .next(); + } + + @Override + public Flux listSnapshots(Snapshot.SubjectRef subjectRef) { + Assert.notNull(subjectRef, "The subjectRef must not be null."); + return client.list(Snapshot.class, snapshot -> subjectRef.equals(snapshot.getSpec() + .getSubjectRef()), null); + } + + private Mono getContextUsername() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .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, + ContentRequest contentRequest) { + String originalRaw = baseSnapshot.getSpec().getRawPatch(); + String originalContent = baseSnapshot.getSpec().getContentPatch(); + + // it is the v1 snapshot, set the content directly + if (snapshotToUse.getSpec().getVersion() == 1) { + snapshotToUse.getSpec().setRawPatch(contentRequest.raw()); + snapshotToUse.getSpec().setContentPatch(contentRequest.content()); + } else { + // otherwise diff a patch based on the v1 snapshot + String revisedRaw = contentRequest.rawPatchFrom(originalRaw); + String revisedContent = contentRequest.contentPatchFrom(originalContent); + snapshotToUse.getSpec().setRawPatch(revisedRaw); + snapshotToUse.getSpec().setContentPatch(revisedContent); + } + } +} diff --git a/src/main/java/run/halo/app/content/impl/PostServiceImpl.java b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java new file mode 100644 index 000000000..5ecad6930 --- /dev/null +++ b/src/main/java/run/halo/app/content/impl/PostServiceImpl.java @@ -0,0 +1,237 @@ +package run.halo.app.content.impl; + +import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; + +import java.security.Principal; +import java.time.Instant; +import java.util.Collection; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Predicate; +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 reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.ContentService; +import run.halo.app.content.Contributor; +import run.halo.app.content.ListedPost; +import run.halo.app.content.PostQuery; +import run.halo.app.content.PostRequest; +import run.halo.app.content.PostService; +import run.halo.app.core.extension.Category; +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; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.Condition; +import run.halo.app.infra.ConditionStatus; + +/** + * A default implementation of {@link PostService}. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class PostServiceImpl implements PostService { + private static final Comparator DEFAULT_POST_COMPARATOR = + Comparator.comparing(post -> post.getMetadata().getCreationTimestamp()); + private final ContentService contentService; + private final ReactiveExtensionClient client; + + public PostServiceImpl(ContentService contentService, ReactiveExtensionClient client) { + this.contentService = contentService; + this.client = client; + } + + @Override + public Mono> listPost(PostQuery query) { + return client.list(Post.class, postListPredicate(query), + DEFAULT_POST_COMPARATOR.reversed(), query.getPage(), query.getSize()) + .flatMap(listResult -> Flux.fromStream( + listResult.get().map(this::getListedPost) + ) + .flatMap(Function.identity()) + .collectList() + .map(listedPosts -> new ListResult<>(listResult.getPage(), listResult.getSize(), + listResult.getTotal(), listedPosts) + ) + ); + } + + Predicate postListPredicate(PostQuery query) { + Predicate paramPredicate = post -> + contains(query.getCategories(), post.getSpec().getCategories()) + && contains(query.getTags(), post.getSpec().getTags()) + && contains(query.getContributors(), post.getStatus().getContributors()); + Predicate predicate = labelAndFieldSelectorToPredicate(query.getLabelSelector(), + query.getFieldSelector()); + return predicate.and(paramPredicate); + } + + boolean contains(Collection left, List right) { + // parameter is null, it means that ignore this condition + if (left == null) { + return true; + } + // else, it means that right is empty + if (left.isEmpty()) { + return right.isEmpty(); + } + if (right == null) { + return false; + } + return right.stream().anyMatch(left::contains); + } + + private Mono getListedPost(Post post) { + Assert.notNull(post, "The post must not be null."); + ListedPost listedPost = new ListedPost(); + listedPost.setPost(post); + return Mono.zip(listTags(post.getSpec().getTags()), + listCategories(post.getSpec().getCategories()), + listContributors(post.getStatusOrDefault().getContributors()) + ) + .map(tuple -> { + List tags = tuple.getT1(); + List categories = tuple.getT2(); + List contributors = tuple.getT3(); + listedPost.setTags(tags); + listedPost.setCategories(categories); + listedPost.setContributors(contributors); + return listedPost; + }); + } + + private Mono> listTags(List tagNames) { + if (tagNames == null) { + return Mono.empty(); + } + return Flux.fromStream(tagNames.stream() + .map(tagName -> client.fetch(Tag.class, tagName))) + .flatMap(Function.identity()) + .collectList(); + } + + private Mono> listCategories(List categoryNames) { + if (categoryNames == null) { + return Mono.empty(); + } + return Flux.fromStream(categoryNames.stream() + .map(categoryName -> client.fetch(Category.class, categoryName))) + .flatMap(Function.identity()) + .collectList(); + } + + private Mono> listContributors(List usernames) { + if (usernames == null) { + return Mono.empty(); + } + return Flux.fromIterable(usernames) + .map(username -> client.fetch(User.class, username) + .map(user -> { + Contributor contributor = new Contributor(); + contributor.setName(username); + contributor.setDisplayName(user.getSpec().getDisplayName()); + contributor.setAvatar(user.getSpec().getAvatar()); + return contributor; + }) + ) + .flatMap(Function.identity()) + .collectList(); + } + + @Override + public Mono draftPost(PostRequest postRequest) { + return contentService.draftContent(postRequest.contentRequest()) + .flatMap(contentWrapper -> getContextUsername() + .flatMap(username -> { + Post post = postRequest.post(); + post.getSpec().setBaseSnapshot(contentWrapper.snapshotName()); + post.getSpec().setHeadSnapshot(contentWrapper.snapshotName()); + post.getSpec().setOwner(username); + appendPublishedCondition(post, Post.PostPhase.DRAFT); + return client.create(post) + .then(Mono.defer(() -> + client.fetch(Post.class, postRequest.post().getMetadata().getName()))); + })); + } + + @Override + public Mono updatePost(PostRequest postRequest) { + Post post = postRequest.post(); + return contentService.updateContent(postRequest.contentRequest()) + .flatMap(contentWrapper -> { + post.getSpec().setHeadSnapshot(contentWrapper.snapshotName()); + return client.update(post); + }) + .then(Mono.defer(() -> client.fetch(Post.class, post.getMetadata().getName()))); + } + + private Mono getContextUsername() { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .map(Principal::getName); + } + + @Override + public Mono publishPost(String postName) { + return client.fetch(Post.class, postName) + .flatMap(post -> { + Post.PostSpec spec = post.getSpec(); + // publish snapshot + return Mono.zip(Mono.just(post), + client.fetch(Snapshot.class, spec.getHeadSnapshot())); + }) + .flatMap(tuple -> { + Post post = tuple.getT1(); + Snapshot snapshot = tuple.getT2(); + + Post.PostSpec postSpec = post.getSpec(); + if (Objects.equals(true, postSpec.getPublished())) { + // has been published before + postSpec.setVersion(postSpec.getVersion() + 1); + } else { + postSpec.setPublished(true); + } + + if (postSpec.getPublishTime() == null) { + postSpec.setPublishTime(Instant.now()); + } + + // update release snapshot name and condition + postSpec.setReleaseSnapshot(snapshot.getMetadata().getName()); + appendPublishedCondition(post, Post.PostPhase.PUBLISHED); + + Snapshot.SubjectRef subjectRef = + Snapshot.SubjectRef.of(Post.KIND, post.getMetadata().getName()); + return contentService.publish(snapshot.getMetadata().getName(), subjectRef) + .flatMap(contentWrapper -> { + post.getSpec().setReleaseSnapshot(contentWrapper.snapshotName()); + return client.update(post); + }) + .then(Mono.defer(() -> client.fetch(Post.class, postName))); + }); + } + + 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(); + Condition condition = new Condition(); + conditions.add(condition); + + condition.setType(phase.name()); + condition.setReason(phase.name()); + condition.setMessage(""); + condition.setStatus(ConditionStatus.TRUE); + condition.setLastTransitionTime(Instant.now()); + } +} diff --git a/src/main/java/run/halo/app/core/extension/Category.java b/src/main/java/run/halo/app/core/extension/Category.java new file mode 100644 index 000000000..cfc3c410a --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Category.java @@ -0,0 +1,60 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", + kind = "Category", plural = "categories", singular = "category") +@EqualsAndHashCode(callSuper = true) +public class Category extends AbstractExtension { + + @Schema(required = true) + private CategorySpec spec; + + @Schema + private CategoryStatus status; + + @Data + public static class CategorySpec { + + @Schema(required = true, minLength = 1) + private String displayName; + + @Schema(required = true, minLength = 1) + private String slug; + + private String description; + + private String cover; + + private String template; + + @Schema(required = true, defaultValue = "0") + private Integer priority; + + private List children; + } + + @Data + public static class CategoryStatus { + + private String permalink; + + /** + * 包括当前和其下所有层级的文章 name (depth=max). + */ + private List posts; + } +} diff --git a/src/main/java/run/halo/app/core/extension/Comment.java b/src/main/java/run/halo/app/core/extension/Comment.java new file mode 100644 index 000000000..323513c50 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Comment.java @@ -0,0 +1,88 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Map; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", + kind = Comment.KIND, plural = "comments", singular = "comment") +@EqualsAndHashCode(callSuper = true) +public class Comment extends AbstractExtension { + public static final String KIND = "Comment"; + + @Schema(required = true) + private CommentSpec spec; + + @Data + @EqualsAndHashCode(callSuper = true) + public static class CommentSpec extends BaseCommentSpec { + + @Schema(required = true) + private CommentSubjectRef subjectRef; + } + + @Data + public static class BaseCommentSpec { + + @Schema(required = true, minLength = 1) + private String raw; + + @Schema(required = true, minLength = 1) + private String content; + + @Schema(required = true) + private CommentOwner owner; + + private String userAgent; + + private String ipAddress; + + @Schema(required = true, defaultValue = "0") + private Integer priority; + + @Schema(required = true, defaultValue = "false") + private Boolean top; + + @Schema(required = true, defaultValue = "true") + private Boolean allowNotification; + + @Schema(required = true, defaultValue = "false") + private Boolean approved; + + @Schema(required = true, defaultValue = "false") + private Boolean hidden; + } + + @Data + public static class CommentOwner { + @Schema(required = true, minLength = 1) + private String kind; + + @Schema(required = true, minLength = 1, maxLength = 64) + private String name; + + private String displayName; + + private Map annotations; + } + + @Data + public static class CommentSubjectRef { + @Schema(required = true, minLength = 1) + private String kind; + + @Schema(required = true, minLength = 1, maxLength = 64) + private String name; + } +} diff --git a/src/main/java/run/halo/app/core/extension/Post.java b/src/main/java/run/halo/app/core/extension/Post.java new file mode 100644 index 000000000..dc5e8ebcb --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Post.java @@ -0,0 +1,152 @@ +package run.halo.app.core.extension; + +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 lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; +import run.halo.app.infra.Condition; + +/** + *

Post extension.

+ * + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", kind = Post.KIND, + plural = "posts", singular = "post") +@EqualsAndHashCode(callSuper = true) +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 TAGS_ANNO = "content.halo.run/tags"; + public static final String DELETED_LABEL = "content.halo.run/deleted"; + public static final String OWNER_LABEL = "content.halo.run/owner"; + public static final String VISIBLE_LABEL = "content.halo.run/visible"; + public static final String PHASE_LABEL = "content.halo.run/phase"; + + @Schema(required = true) + private PostSpec spec; + + @Schema + private PostStatus status; + + @JsonIgnore + public PostStatus getStatusOrDefault() { + if (this.status == null) { + this.status = new PostStatus(); + } + return status; + } + + @Data + public static class PostSpec { + @Schema(required = true, minLength = 1) + private String title; + + @Schema(required = true, minLength = 1) + private String slug; + + /** + * 文章引用到的已发布的内容,用于主题端显示. + */ + private String releaseSnapshot; + + private String headSnapshot; + + private String baseSnapshot; + + private String owner; + + private String template; + + private String cover; + + @Schema(required = true, defaultValue = "false") + private Boolean deleted; + + @Schema(required = true, defaultValue = "false") + private Boolean published; + + private Instant publishTime; + + @Schema(required = true, defaultValue = "false") + private Boolean pinned; + + @Schema(required = true, defaultValue = "true") + private Boolean allowComment; + + @Schema(required = true, defaultValue = "PUBLIC") + private VisibleEnum visible; + + @Schema(required = true, defaultValue = "1") + private Integer version; + + @Schema(required = true, defaultValue = "0") + private Integer priority; + + @Schema(required = true) + private Excerpt excerpt; + + private List categories; + + private List tags; + + private List> htmlMetas; + } + + @Data + public static class PostStatus { + @Schema(required = true) + private String phase; + + @Schema + private List conditions; + + private String permalink; + + private String excerpt; + + private Boolean inProgress; + + private List contributors; + + @JsonIgnore + public List getConditionsOrDefault() { + if (this.conditions == null) { + this.conditions = new ArrayList<>(); + } + return conditions; + } + } + + @Data + public static class Excerpt { + + @Schema(required = true, defaultValue = "true") + private Boolean autoGenerate; + + private String raw; + } + + public enum PostPhase { + DRAFT, + PENDING_APPROVAL, + PUBLISHED + } + + public enum VisibleEnum { + PUBLIC, + INTERNAL, + PRIVATE + } +} diff --git a/src/main/java/run/halo/app/core/extension/Reply.java b/src/main/java/run/halo/app/core/extension/Reply.java new file mode 100644 index 000000000..ca10dcdc9 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Reply.java @@ -0,0 +1,36 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", + kind = Reply.KIND, plural = "replies", singular = "reply") +@EqualsAndHashCode(callSuper = true) +public class Reply extends AbstractExtension { + + public static final String KIND = "Reply"; + + @Schema(required = true) + private ReplySpec spec; + + @Data + @EqualsAndHashCode(callSuper = true) + public static class ReplySpec extends Comment.BaseCommentSpec { + + @Schema(required = true, minLength = 1) + private String commentName; + + private String quoteReply; + } +} diff --git a/src/main/java/run/halo/app/core/extension/Snapshot.java b/src/main/java/run/halo/app/core/extension/Snapshot.java new file mode 100644 index 000000000..073ffd31d --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Snapshot.java @@ -0,0 +1,129 @@ +package run.halo.app.core.extension; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.LinkedHashSet; +import java.util.Set; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.util.Assert; +import run.halo.app.content.ContentWrapper; +import run.halo.app.content.PatchUtils; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", + kind = Snapshot.KIND, plural = "snapshots", singular = "snapshot") +@EqualsAndHashCode(callSuper = true) +public class Snapshot extends AbstractExtension { + public static final String KIND = "Snapshot"; + + @Schema(required = true) + private SnapShotSpec spec; + + @Data + public static class SnapShotSpec { + + @Schema(required = true) + private SubjectRef subjectRef; + + /** + * such as: markdown | html | json | asciidoc | latex. + */ + @Schema(required = true, minLength = 1, maxLength = 50) + private String rawType; + + private String rawPatch; + + private String contentPatch; + + private String parentSnapshotName; + + @Schema(required = true) + private String displayVersion; + + @Schema(required = true, defaultValue = "1") + private Integer version; + + private Instant publishTime; + + private Set contributors; + + @JsonIgnore + public Set getContributorsOrDefault() { + if (this.contributors == null) { + this.contributors = new LinkedHashSet<>(); + } + return this.contributors; + } + } + + @Data + @EqualsAndHashCode + public static class SubjectRef { + @Schema(required = true) + private String kind; + + @Schema(required = true) + private String name; + + public static SubjectRef of(String kind, String name) { + SubjectRef subjectRef = new SubjectRef(); + subjectRef.setKind(kind); + subjectRef.setName(name); + return subjectRef; + } + } + + public static String displayVersionFrom(Integer version) { + Assert.notNull(version, "The version must not be null"); + return "v" + version; + } + + @JsonIgnore + public boolean isPublished() { + return this.spec.getPublishTime() != null; + } + + @JsonIgnore + public void addContributor(String name) { + Assert.notNull(name, "The username must not be null."); + Set contributors = spec.getContributorsOrDefault(); + contributors.add(name); + } + + @JsonIgnore + public void setSubjectRef(String kind, String name) { + Assert.notNull(kind, "The subject kind must not be null."); + Assert.notNull(name, "The subject name must not be null."); + if (spec.subjectRef == null) { + spec.subjectRef = new SubjectRef(); + } + spec.subjectRef.setKind(kind); + spec.subjectRef.setName(name); + } + + @JsonIgnore + public ContentWrapper applyPatch(Snapshot baseSnapshot) { + Assert.notNull(baseSnapshot, "The baseSnapshot must not be null."); + if (this.spec.version == 1) { + return new ContentWrapper(this.getMetadata().getName(), this.spec.rawPatch, + this.spec.contentPatch, this.spec.rawType); + } + String patchedContent = + PatchUtils.applyPatch(baseSnapshot.getSpec().getContentPatch(), this.spec.contentPatch); + String patchedRaw = + PatchUtils.applyPatch(baseSnapshot.getSpec().getRawPatch(), this.spec.rawPatch); + return new ContentWrapper(this.getMetadata().getName(), patchedRaw, + patchedContent, this.spec.rawType); + } +} diff --git a/src/main/java/run/halo/app/core/extension/Tag.java b/src/main/java/run/halo/app/core/extension/Tag.java new file mode 100644 index 000000000..8ab5a635b --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Tag.java @@ -0,0 +1,65 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @see issue#2322 + * @since 2.0.0 + */ +@Data +@ToString(callSuper = true) +@GVK(group = "content.halo.run", version = "v1alpha1", + kind = Tag.KIND, plural = "tags", singular = "tag") +@EqualsAndHashCode(callSuper = true) +public class Tag extends AbstractExtension { + public static final String KIND = "Tag"; + + @Schema(required = true) + private TagSpec spec; + + @Schema + private TagStatus status; + + @Data + public static class TagSpec { + + @Schema(required = true, minLength = 1) + private String displayName; + + @Schema(required = true, minLength = 1) + private String slug; + + /** + * Color regex explanation. + *
+         * ^                 # start of the line
+         * #                 # start with a number sign `#`
+         * (                 # start of (group 1)
+         *   [a-fA-F0-9]{6}  # support z-f, A-F and 0-9, with a length of 6
+         *   |               # or
+         *   [a-fA-F0-9]{3}  # support z-f, A-F and 0-9, with a length of 3
+         * )                 # end of (group 1)
+         * $                 # end of the line
+         * 
+ */ + @Schema(pattern = "^#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$") + private String color; + + private String cover; + } + + @Data + public static class TagStatus { + + private String permalink; + + private List posts; + } +} diff --git a/src/main/java/run/halo/app/core/extension/endpoint/ContentEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/ContentEndpoint.java new file mode 100644 index 000000000..24d0c7c18 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/ContentEndpoint.java @@ -0,0 +1,135 @@ +package run.halo.app.core.extension.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springdoc.core.fn.builders.schema.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.http.MediaType; +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 reactor.core.publisher.Mono; +import run.halo.app.content.ContentRequest; +import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; +import run.halo.app.core.extension.Snapshot; + +/** + * Endpoint for managing content. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class ContentEndpoint implements CustomEndpoint { + + private final ContentService contentService; + + public ContentEndpoint(ContentService contentService) { + this.contentService = contentService; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/Content"; + return SpringdocRouteBuilder.route() + .GET("contents/{snapshotName}", this::obtainContent, + builder -> builder.operationId("ObtainSnapshotContent") + .description("Obtain a snapshot content.") + .tag(tag) + .parameter(parameterBuilder() + .required(true) + .name("snapshotName") + .in(ParameterIn.PATH)) + .response(responseBuilder() + .implementation(ContentWrapper.class)) + ) + .POST("contents", this::draftSnapshotContent, + builder -> builder.operationId("DraftSnapshotContent") + .description("Draft a snapshot content.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(ContentRequest.class)) + )) + .response(responseBuilder() + .implementation(ContentWrapper.class)) + ) + .PUT("contents/{snapshotName}", this::updateSnapshotContent, + builder -> builder.operationId("UpdateSnapshotContent") + .description("Update a snapshot content.") + .tag(tag) + .parameter(parameterBuilder() + .required(true) + .name("snapshotName") + .in(ParameterIn.PATH)) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(ContentRequest.class)) + )) + .response(responseBuilder() + .implementation(ContentWrapper.class)) + ) + .PUT("contents/{snapshotName}/publish", this::publishSnapshotContent, + builder -> builder.operationId("PublishSnapshotContent") + .description("Publish a snapshot content.") + .tag(tag) + .parameter(parameterBuilder() + .required(true) + .name("snapshotName") + .in(ParameterIn.PATH)) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(Snapshot.SubjectRef.class)) + )) + .response(responseBuilder() + .implementation(ContentWrapper.class)) + ) + .build(); + } + + private Mono obtainContent(ServerRequest request) { + String snapshotName = request.pathVariable("snapshotName"); + return contentService.getContent(snapshotName) + .flatMap(content -> ServerResponse.ok().bodyValue(content)); + } + + private Mono updateSnapshotContent(ServerRequest request) { + String snapshotName = request.pathVariable("snapshotName"); + return request.bodyToMono(ContentRequest.class) + .flatMap(content -> { + ContentRequest contentRequest = + new ContentRequest(content.subjectRef(), snapshotName, + content.raw(), content.content(), content.rawType()); + return contentService.updateContent(contentRequest); + }) + .flatMap(content -> ServerResponse.ok().bodyValue(content)); + } + + private Mono publishSnapshotContent(ServerRequest request) { + String snapshotName = request.pathVariable("snapshotName"); + return request.bodyToMono(Snapshot.SubjectRef.class) + .flatMap(subjectRef -> contentService.publish(snapshotName, subjectRef)) + .flatMap(content -> ServerResponse.ok().bodyValue(content)); + } + + private Mono draftSnapshotContent(ServerRequest request) { + return request.bodyToMono(ContentRequest.class) + .flatMap(contentService::draftContent) + .flatMap(content -> ServerResponse.ok().bodyValue(content)); + } +} 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 new file mode 100644 index 000000000..9a8ecfbe7 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/endpoint/PostEndpoint.java @@ -0,0 +1,140 @@ +package run.halo.app.core.extension.endpoint; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springdoc.core.fn.builders.schema.Builder; +import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; +import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.http.MediaType; +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 reactor.core.publisher.Mono; +import run.halo.app.content.ListedPost; +import run.halo.app.content.PostQuery; +import run.halo.app.content.PostRequest; +import run.halo.app.content.PostService; +import run.halo.app.core.extension.Post; +import run.halo.app.extension.ListResult; +import run.halo.app.extension.router.QueryParamBuildUtil; + +/** + * Endpoint for managing posts. + * + * @author guqing + * @since 2.0.0 + */ +@Component +public class PostEndpoint implements CustomEndpoint { + + private final PostService postService; + + public PostEndpoint(PostService postService) { + this.postService = postService; + } + + @Override + public RouterFunction endpoint() { + final var tag = "api.halo.run/v1alpha1/Post"; + return SpringdocRouteBuilder.route() + .GET("posts", this::listPost, builder -> { + builder.operationId("ListPosts") + .description("List posts.") + .tag(tag) + .response(responseBuilder() + .implementation(ListResult.generateGenericClass(ListedPost.class)) + ); + QueryParamBuildUtil.buildParametersFromType(builder, PostQuery.class); + } + ) + .POST("posts", this::draftPost, + builder -> builder.operationId("DraftPost") + .description("Draft a post.") + .tag(tag) + .requestBody(requestBodyBuilder() + .required(true) + .content(contentBuilder() + .mediaType(MediaType.APPLICATION_JSON_VALUE) + .schema(Builder.schemaBuilder() + .implementation(PostRequest.class)) + )) + .response(responseBuilder() + .implementation(Post.class)) + ) + .PUT("posts/{name}", this::updatePost, + builder -> builder.operationId("UpdateDraftPost") + .description("Update a post.") + .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.class)) + )) + .response(responseBuilder() + .implementation(Post.class)) + ) + .PUT("posts/{name}/publish", this::publishPost, + builder -> builder.operationId("PublishPost") + .description("Publish a post.") + .tag(tag) + .parameter(parameterBuilder().name("name") + .in(ParameterIn.PATH) + .required(true) + .implementation(String.class)) + .response(responseBuilder() + .implementation(Post.class)) + ) + .build(); + } + + Mono draftPost(ServerRequest request) { + return request.bodyToMono(PostRequest.class) + .flatMap(postService::draftPost) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + + Mono updatePost(ServerRequest request) { + return request.bodyToMono(PostRequest.class) + .flatMap(postService::updatePost) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + + Mono publishPost(ServerRequest request) { + String name = request.pathVariable("name"); + return postService.publishPost(name) + .flatMap(post -> ServerResponse.ok().bodyValue(post)); + } + + Mono listPost(ServerRequest request) { + var conversionService = ApplicationConversionService.getSharedInstance(); + var page = + request.queryParam("page") + .map(pageString -> conversionService.convert(pageString, Integer.class)) + .orElse(0); + + var size = request.queryParam("size") + .map(sizeString -> conversionService.convert(sizeString, Integer.class)) + .orElse(0); + + var labelSelectors = request.queryParams().get("labelSelector"); + var fieldSelectors = request.queryParams().get("fieldSelector"); + PostQuery postQuery = new PostQuery(); + postQuery.setPage(page); + postQuery.setSize(size); + postQuery.setLabelSelector(labelSelectors); + postQuery.setFieldSelector(fieldSelectors); + return postService.listPost(postQuery) + .flatMap(listedPosts -> ServerResponse.ok().bodyValue(listedPosts)); + } +} 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 new file mode 100644 index 000000000..fa3d54290 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/reconciler/PostReconciler.java @@ -0,0 +1,151 @@ +package run.halo.app.core.extension.reconciler; + +import java.time.Instant; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.springframework.util.Assert; +import run.halo.app.content.ContentService; +import run.halo.app.core.extension.Post; +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; +import run.halo.app.infra.ConditionStatus; +import run.halo.app.infra.utils.JsonUtils; + +/** + *

Reconciler for {@link Post}.

+ * + *

things to do:

+ *
    + * 1. generate permalink + * 2. generate excerpt if auto generate is enabled + *
+ * + * @author guqing + * @since 2.0.0 + */ +public class PostReconciler implements Reconciler { + public static final String PERMALINK_PREFIX = "/permalink/posts/"; + private final ExtensionClient client; + private final ContentService contentService; + + public PostReconciler(ExtensionClient client, ContentService contentService) { + this.client = client; + this.contentService = contentService; + } + + @Override + public Result reconcile(Request request) { + client.fetch(Post.class, request.name()) + .ifPresent(post -> { + Post oldPost = JsonUtils.deepCopy(post); + + doReconcile(post); + + if (!oldPost.equals(post)) { + client.update(post); + } + }); + return new Result(false, null); + } + + private void doReconcile(Post post) { + String name = post.getMetadata().getName(); + Post.PostSpec spec = post.getSpec(); + Post.PostStatus status = post.getStatusOrDefault(); + if (status.getPhase() == null) { + status.setPhase(Post.PostPhase.DRAFT.name()); + } + // handle permalink + if (StringUtils.isBlank(status.getPermalink())) { + status.setPermalink(PERMALINK_PREFIX + name); + } + + // handle excerpt + Post.Excerpt excerpt = spec.getExcerpt(); + if (excerpt == null) { + excerpt = new Post.Excerpt(); + excerpt.setAutoGenerate(true); + spec.setExcerpt(excerpt); + } + + if (excerpt.getAutoGenerate()) { + contentService.getContent(spec.getHeadSnapshot()) + .subscribe(content -> { + String contentRevised = content.content(); + status.setExcerpt(getExcerpt(contentRevised)); + }); + } else { + status.setExcerpt(excerpt.getRaw()); + } + + // handle contributors + contentService.listSnapshots(Snapshot.SubjectRef.of(Post.KIND, name)) + .collectList() + .subscribe(snapshots -> { + List contributors = snapshots.stream() + .map(snapshot -> { + Set usernames = snapshot.getSpec().getContributors(); + return Objects.requireNonNullElseGet(usernames, + () -> new HashSet()); + }) + .flatMap(Set::stream) + .distinct() + .sorted() + .toList(); + status.setContributors(contributors); + }); + + // handle cancel publish,has released version and published is false and not handled + if (StringUtils.isNotBlank(spec.getReleaseSnapshot()) + && Objects.equals(false, spec.getPublished()) + && !StringUtils.equals(status.getPhase(), Post.PostPhase.DRAFT.name())) { + Condition condition = new Condition(); + condition.setType("CancelledPublish"); + condition.setStatus(ConditionStatus.TRUE); + condition.setReason(condition.getType()); + condition.setMessage(StringUtils.EMPTY); + condition.setLastTransitionTime(Instant.now()); + status.getConditionsOrDefault().add(condition); + status.setPhase(Post.PostPhase.DRAFT.name()); + } + + // handle logic delete + Map labels = getLabelsOrDefault(post); + if (Objects.equals(spec.getDeleted(), true)) { + labels.put(Post.DELETED_LABEL, Boolean.TRUE.toString()); + // TODO do more about logic delete such as remove router + } else { + labels.put(Post.DELETED_LABEL, Boolean.FALSE.toString()); + } + // synchronize some fields to labels to query + labels.put(Post.PHASE_LABEL, status.getPhase()); + labels.put(Post.VISIBLE_LABEL, + Objects.requireNonNullElse(spec.getVisible(), Post.VisibleEnum.PUBLIC).name()); + labels.put(Post.OWNER_LABEL, spec.getOwner()); + } + + private Map getLabelsOrDefault(Post post) { + Assert.notNull(post, "The post must not be null."); + Map labels = post.getMetadata().getLabels(); + if (labels == null) { + labels = new LinkedHashMap<>(); + post.getMetadata().setLabels(labels); + } + return labels; + } + + private String getExcerpt(String htmlContent) { + String shortHtmlContent = StringUtils.substring(htmlContent, 0, 500); + String text = Jsoup.parse(shortHtmlContent).text(); + // TODO The default capture 150 words as excerpt + return StringUtils.substring(text, 0, 150); + } +} diff --git a/src/main/java/run/halo/app/extension/ListResult.java b/src/main/java/run/halo/app/extension/ListResult.java index 909dc523e..cf2c04b1c 100644 --- a/src/main/java/run/halo/app/extension/ListResult.java +++ b/src/main/java/run/halo/app/extension/ListResult.java @@ -105,4 +105,23 @@ public class ListResult implements Streamable { .load(ListResult.class.getClassLoader()) .getLoaded(); } + + /** + * Generate generic ListResult class. Like {@code ListResult}, {@code ListResult}, + * etc. + * + * @param type the generic type of {@link ListResult}. + * @return generic ListResult class. + */ + public static Class generateGenericClass(Class type) { + var generic = + TypeDescription.Generic.Builder.parameterizedType(ListResult.class, type) + .build(); + return new ByteBuddy() + .subclass(generic) + .name(type.getSimpleName() + "List") + .make() + .load(ListResult.class.getClassLoader()) + .getLoaded(); + } } diff --git a/src/main/java/run/halo/app/infra/Condition.java b/src/main/java/run/halo/app/infra/Condition.java new file mode 100644 index 000000000..eed971d66 --- /dev/null +++ b/src/main/java/run/halo/app/infra/Condition.java @@ -0,0 +1,50 @@ +package run.halo.app.infra; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import lombok.Data; + +/** + * @author guqing + * @see + * pod-conditions + * @since 2.0.0 + */ +@Data +public class Condition { + /** + * type of condition in CamelCase or in foo.example.com/CamelCase. + * example: Ready, Initialized. + * maxLength: 316. + */ + @Schema(required = true, maxLength = 316, + pattern = "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(" + + "([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$") + private String type; + + /** + * Status is the status of the condition. Can be True, False, Unknown. + */ + @Schema(required = true) + private ConditionStatus status; + + /** + * Last time the condition transitioned from one status to another. + */ + @Schema(required = true) + private Instant lastTransitionTime; + + /** + * Human-readable message indicating details about last transition. + * This may be an empty string. + */ + @Schema(required = true, maxLength = 32768) + private String message; + + /** + * Unique, one-word, CamelCase reason for the condition's last transition. + */ + @Schema(required = true, maxLength = 1024, + pattern = "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$") + private String reason; +} diff --git a/src/main/java/run/halo/app/infra/ConditionStatus.java b/src/main/java/run/halo/app/infra/ConditionStatus.java new file mode 100644 index 000000000..24e0baed0 --- /dev/null +++ b/src/main/java/run/halo/app/infra/ConditionStatus.java @@ -0,0 +1,11 @@ +package run.halo.app.infra; + +/** + * @author guqing + * @since 2.0.0 + */ +public enum ConditionStatus { + TRUE, + FALSE, + UNKNOWN +} diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index a5d8f6e24..482b70fdb 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -4,13 +4,19 @@ import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; +import run.halo.app.core.extension.Category; +import run.halo.app.core.extension.Comment; import run.halo.app.core.extension.Menu; import run.halo.app.core.extension.MenuItem; import run.halo.app.core.extension.Plugin; +import run.halo.app.core.extension.Post; +import run.halo.app.core.extension.Reply; import run.halo.app.core.extension.ReverseProxy; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.Setting; +import run.halo.app.core.extension.Snapshot; +import run.halo.app.core.extension.Tag; import run.halo.app.core.extension.Theme; import run.halo.app.core.extension.User; import run.halo.app.extension.ConfigMap; @@ -39,5 +45,11 @@ public class SchemeInitializer implements ApplicationListenerFour score and seven

+

years ago our fathers

+
+

brought forth on this continent

+ """, + "MARKDOWN"); + } + + @Test + void toSnapshot() throws JSONException { + String expectedContentPath = + "

Four score and seven

\n

years ago our fathers

\n
\n

brought forth " + + "on this continent

\n"; + String expectedRawPatch = + "Four score and seven\nyears ago our fathers\n\nbrought forth on this continent\n"; + Snapshot snapshot = contentRequest.toSnapshot(); + snapshot.getMetadata().setName("7b149646-ac60-4a5c-98ee-78b2dd0631b2"); + JSONAssert.assertEquals(JsonUtils.objectToJson(snapshot), + """ + { + "spec": { + "subjectRef": { + "kind": "Post", + "name": "test-post" + }, + "rawType": "MARKDOWN", + "rawPatch": "%s", + "contentPatch": "%s", + "parentSnapshotName": null, + "displayVersion": "v1", + "version": 1, + "publishTime": null, + "contributors": null + }, + "apiVersion": "content.halo.run/v1alpha1", + "kind": "Snapshot", + "metadata": { + "name": "7b149646-ac60-4a5c-98ee-78b2dd0631b2", + "labels": null, + "annotations": null, + "version": null, + "creationTimestamp": null, + "deletionTimestamp": null + } + } + """.formatted(expectedRawPatch, expectedContentPath), + true); + } + + @Test + void rawPatchFrom() throws JSONException { + String s = contentRequest.rawPatchFrom(""" + Four score and seven + years ago our fathers + """); + JSONAssert.assertEquals(s, + """ + [ + { + "source": { + "position": 3, + "lines": [], + "changePosition": null + }, + "target": { + "position": 3, + "lines": [ + "brought forth on this continent", + "" + ], + "changePosition": null + }, + "type": "INSERT" + } + ] + """, true); + } + + @Test + void contentPatchFrom() throws JSONException { + String s = contentRequest.contentPatchFrom(""" +

Four score and seven

+

years ago our fathers

+ """); + JSONAssert.assertEquals(s, """ + [ + { + "source": { + "position": 2, + "lines": [], + "changePosition": null + }, + "target": { + "position": 2, + "lines": [ + "
", + "

brought forth on this continent

" + ], + "changePosition": null + }, + "type": "INSERT" + } + ] + """, true); + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/ContentServiceTest.java b/src/test/java/run/halo/app/content/ContentServiceTest.java new file mode 100644 index 000000000..a77c55638 --- /dev/null +++ b/src/test/java/run/halo/app/content/ContentServiceTest.java @@ -0,0 +1,363 @@ +package run.halo.app.content; + +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 static run.halo.app.content.TestPost.snapshotV1; +import static run.halo.app.content.TestPost.snapshotV2; +import static run.halo.app.content.TestPost.snapshotV3; + +import java.time.Instant; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +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.ReactiveExtensionClient; +import run.halo.app.infra.utils.JsonUtils; + +/** + * Tests for {@link ContentService}. + * + * @author guqing + * @since 2.0.0 + */ +@WithMockUser(username = "guqing") +@ExtendWith(SpringExtension.class) +class ContentServiceTest { + + @Mock + private ReactiveExtensionClient client; + + private ContentService contentService; + + @BeforeEach + void setUp() { + contentService = new ContentServiceImpl(client); + } + + @Test + void draftContent() { + Snapshot snapshotV1 = snapshotV1(); + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + snapshotV1.getSpec().setSubjectRef(subjectRef); + + ContentRequest contentRequest = + new ContentRequest(subjectRef, null, + snapshotV1.getSpec().getRawPatch(), + snapshotV1.getSpec().getContentPatch(), + snapshotV1.getSpec().getRawType()); + + pilingBaseSnapshot(snapshotV1); + + ContentWrapper contentWrapper = + new ContentWrapper("snapshot-A", contentRequest.raw(), + contentRequest.content(), snapshotV1.getSpec().getRawType()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Snapshot.class); + when(client.create(any())).thenReturn(Mono.just(snapshotV1)); + + StepVerifier.create(contentService.draftContent(contentRequest)) + .expectNext(contentWrapper) + .expectComplete() + .verify(); + + verify(client, times(1)).create(captor.capture()); + Snapshot snapshot = captor.getValue(); + + snapshotV1.getMetadata().setName(snapshot.getMetadata().getName()); + snapshotV1.getSpec().setSubjectRef(subjectRef); + assertThat(snapshot).isEqualTo(snapshotV1); + } + + @Test + void updateContent() { + String headSnapshot = "snapshot-A"; + Snapshot snapshotV1 = snapshotV1(); + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + + Snapshot updated = snapshotV1(); + updated.getSpec().setRawPatch("hello"); + updated.getSpec().setContentPatch("

hello

"); + updated.getSpec().setSubjectRef(subjectRef); + ContentRequest contentRequest = + new ContentRequest(subjectRef, headSnapshot, + snapshotV1.getSpec().getRawPatch(), + snapshotV1.getSpec().getContentPatch(), + snapshotV1.getSpec().getRawType()); + + pilingBaseSnapshot(snapshotV1); + + when(client.fetch(eq(Snapshot.class), eq(contentRequest.headSnapshotName()))) + .thenReturn(Mono.just(updated)); + + ContentWrapper contentWrapper = + new ContentWrapper(headSnapshot, contentRequest.raw(), + contentRequest.content(), snapshotV1.getSpec().getRawType()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Snapshot.class); + when(client.update(any())).thenReturn(Mono.just(updated)); + + StepVerifier.create(contentService.updateContent(contentRequest)) + .expectNext(contentWrapper) + .expectComplete() + .verify(); + + verify(client, times(1)).update(captor.capture()); + Snapshot snapshot = captor.getValue(); + + assertThat(snapshot).isEqualTo(updated); + } + + @Test + void updateContentWhenHasDraftVersionButHeadPoints2Published() { + final String headSnapshot = "snapshot-A"; + Snapshot snapshotV1 = snapshotV1(); + + Snapshot snapshotV2 = snapshotV2(); + snapshotV2.getSpec().setPublishTime(null); + + + // v1(released),v2 + 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)); + + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + + ContentRequest contentRequest = + new ContentRequest(subjectRef, headSnapshot, "C", + "

C

", snapshotV1.getSpec().getRawType()); + + when(client.create(any())).thenReturn(Mono.just(snapshotV3())); + + StepVerifier.create(contentService.latestSnapshotVersion(subjectRef)) + .expectNext(snapshotV2) + .expectComplete() + .verify(); + + StepVerifier.create(contentService.updateContent(contentRequest)) + .consumeNextWith(created -> { + assertThat(created.raw()).isEqualTo("C"); + assertThat(created.content()).isEqualTo("

C

"); + }) + .expectComplete() + .verify(); + } + + @Test + void updateContentWhenHeadPoints2Published() { + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + + // v1(released),v2 + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.getSpec().setPublishTime(Instant.now()); + snapshotV1.getSpec().setSubjectRef(subjectRef); + + Snapshot snapshotV2 = snapshotV2(); + snapshotV2.getSpec().setSubjectRef(subjectRef); + snapshotV2.getSpec().setPublishTime(null); + + final String headSnapshot = snapshotV2.getMetadata().getName(); + + + 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)); + + ContentRequest contentRequest = + new ContentRequest(subjectRef, headSnapshot, "C", + "

C

", snapshotV1.getSpec().getRawType()); + + when(client.update(any())).thenReturn(Mono.just(snapshotV2())); + + StepVerifier.create(contentService.latestSnapshotVersion(subjectRef)) + .expectNext(snapshotV2) + .expectComplete() + .verify(); + + StepVerifier.create(contentService.updateContent(contentRequest)) + .consumeNextWith(updated -> { + assertThat(updated.raw()).isEqualTo("C"); + assertThat(updated.content()).isEqualTo("

C

"); + }) + .expectComplete() + .verify(); + + verify(client, times(1)).update(any()); + } + + @Test + void publishContent() { + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + + // v1(released),v2 + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.getSpec().setPublishTime(null); + snapshotV1.getSpec().setSubjectRef(subjectRef); + + 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, subjectRef)) + .expectNext() + .consumeNextWith(p -> { + System.out.println(JsonUtils.objectToJson(p)); + }) + .expectComplete() + .verify(); + // has benn published,do nothing + verify(client, times(1)).update(any()); + } + + @Test + void publishContentWhenHasPublishedThenDoNothing() { + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, "test-post"); + + // v1(released),v2 + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.getSpec().setPublishTime(Instant.now()); + snapshotV1.getSpec().setSubjectRef(subjectRef); + + 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, subjectRef)) + .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)); + } + + @Test + void baseSnapshotVersion() { + String postName = "post-1"; + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.getSpec().setPublishTime(Instant.now()); + snapshotV1.setSubjectRef(Post.KIND, postName); + + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV2.setSubjectRef(Post.KIND, postName); + + Snapshot snapshotV3 = TestPost.snapshotV3(); + snapshotV3.setSubjectRef(Post.KIND, postName); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3)); + + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName); + StepVerifier.create(contentService.getBaseSnapshot(subjectRef)) + .expectNext(snapshotV1) + .expectComplete() + .verify(); + } + + @Test + void latestSnapshotVersion() { + String postName = "post-1"; + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.getSpec().setPublishTime(Instant.now()); + snapshotV1.setSubjectRef(Post.KIND, postName); + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV2.setSubjectRef(Post.KIND, postName); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV1, snapshotV2)); + + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName); + StepVerifier.create(contentService.latestSnapshotVersion(subjectRef)) + .expectNext(snapshotV2) + .expectComplete() + .verify(); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3())); + StepVerifier.create(contentService.latestSnapshotVersion(subjectRef)) + .expectNext(snapshotV3()) + .expectComplete() + .verify(); + } + + @Test + void latestPublishedSnapshotThenV1() { + String postName = "post-1"; + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.setSubjectRef(Post.KIND, postName); + snapshotV1.getSpec().setPublishTime(Instant.now()); + + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV2.setSubjectRef(Post.KIND, postName); + snapshotV2.getSpec().setPublishTime(null); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV1, snapshotV2)); + + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName); + StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef)) + .expectNext(snapshotV1) + .expectComplete() + .verify(); + } + + @Test + void latestPublishedSnapshotThenV2() { + String postName = "post-1"; + Snapshot snapshotV1 = snapshotV1(); + snapshotV1.setSubjectRef(Post.KIND, postName); + snapshotV1.getSpec().setPublishTime(Instant.now()); + + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV2.setSubjectRef(Post.KIND, postName); + snapshotV2.getSpec().setPublishTime(Instant.now()); + + when(client.list(eq(Snapshot.class), any(), any())) + .thenReturn(Flux.just(snapshotV2, snapshotV1)); + + Snapshot.SubjectRef subjectRef = Snapshot.SubjectRef.of(Post.KIND, postName); + + StepVerifier.create(contentService.latestPublishedSnapshot(subjectRef)) + .expectNext(snapshotV2) + .expectComplete() + .verify(); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/content/TestPost.java b/src/test/java/run/halo/app/content/TestPost.java new file mode 100644 index 000000000..f9082ed31 --- /dev/null +++ b/src/test/java/run/halo/app/content/TestPost.java @@ -0,0 +1,92 @@ +package run.halo.app.content; + +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.GVK; +import run.halo.app.extension.Metadata; + +/** + * @author guqing + * @since 2.0.0 + */ +public class TestPost { + public static Post postV1() { + Post post = new Post(); + post.setKind(Post.KIND); + post.setApiVersion(getApiVersion(Post.class)); + Metadata metadata = new Metadata(); + metadata.setName("post-A"); + post.setMetadata(metadata); + + Post.PostSpec postSpec = new Post.PostSpec(); + post.setSpec(postSpec); + + postSpec.setTitle("post-A"); + postSpec.setVersion(1); + postSpec.setBaseSnapshot(snapshotV1().getMetadata().getName()); + postSpec.setHeadSnapshot("base-snapshot"); + postSpec.setReleaseSnapshot(null); + + return post; + } + + public static Snapshot snapshotV1() { + Snapshot snapshot = new Snapshot(); + snapshot.setKind(Snapshot.KIND); + snapshot.setApiVersion(getApiVersion(Snapshot.class)); + Metadata metadata = new Metadata(); + metadata.setName("snapshot-A"); + snapshot.setMetadata(metadata); + Snapshot.SnapShotSpec spec = new Snapshot.SnapShotSpec(); + snapshot.setSpec(spec); + + spec.setDisplayVersion("v1"); + spec.setVersion(1); + snapshot.addContributor("guqing"); + spec.setRawType("MARKDOWN"); + spec.setRawPatch("A"); + spec.setContentPatch("

A

"); + + return snapshot; + } + + public static Snapshot snapshotV2() { + Snapshot snapshot = new Snapshot(); + snapshot.setKind(Snapshot.KIND); + snapshot.setApiVersion(getApiVersion(Snapshot.class)); + Metadata metadata = new Metadata(); + 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"); + spec.setRawType("MARKDOWN"); + spec.setRawPatch(PatchUtils.diffToJsonPatch("A", "B")); + spec.setContentPatch(PatchUtils.diffToJsonPatch("

A

", "

B

")); + + return snapshot; + } + + public static Snapshot snapshotV3() { + Snapshot snapshotV3 = snapshotV2(); + snapshotV3.getMetadata().setName("snapshot-C"); + Snapshot.SnapShotSpec spec = snapshotV3.getSpec(); + spec.setDisplayVersion("v3"); + spec.setVersion(3); + snapshotV3.addContributor("guqing"); + spec.setRawType("MARKDOWN"); + spec.setRawPatch(PatchUtils.diffToJsonPatch("B", "C")); + spec.setContentPatch(PatchUtils.diffToJsonPatch("

B

", "

C

")); + + return snapshotV3; + } + + public static String getApiVersion(Class extension) { + GVK annotation = extension.getAnnotation(GVK.class); + return annotation.group() + "/" + annotation.version(); + } +} diff --git a/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java new file mode 100644 index 000000000..d906f444e --- /dev/null +++ b/src/test/java/run/halo/app/content/impl/PostServiceImplTest.java @@ -0,0 +1,77 @@ +package run.halo.app.content.impl; + + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +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.extension.ReactiveExtensionClient; + +/** + * Tests for {@link PostServiceImpl}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PostServiceImplTest { + @Mock + private ReactiveExtensionClient client; + + @Mock + private ContentService contentService; + + private PostServiceImpl postService; + + @BeforeEach + void setUp() { + postService = new PostServiceImpl(contentService, client); + } + + @Test + void listPredicate() { + PostQuery postQuery = new PostQuery(); + postQuery.setCategories(Set.of("category1", "category2")); + + Post post = TestPost.postV1(); + post.getSpec().setTags(null); + post.getStatusOrDefault().setContributors(null); + post.getSpec().setCategories(List.of("category1")); + boolean test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isTrue(); + + post.getSpec().setTags(List.of("tag1")); + test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isTrue(); + + // Do not include tags + postQuery.setTags(Set.of("tag2")); + post.getSpec().setTags(List.of("tag1")); + test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isFalse(); + + postQuery.setTags(Set.of()); + post.getSpec().setTags(List.of()); + test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isTrue(); + + postQuery.setLabelSelector(List.of("hello")); + test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isFalse(); + + postQuery.setLabelSelector(List.of("hello")); + post.getMetadata().setLabels(Map.of("hello", "world")); + test = postService.postListPredicate(postQuery).test(post); + assertThat(test).isTrue(); + } +} \ 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 new file mode 100644 index 000000000..e99e162b6 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/endpoint/PostEndpointTest.java @@ -0,0 +1,86 @@ +package run.halo.app.core.extension.endpoint; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; +import run.halo.app.content.PostRequest; +import run.halo.app.content.PostService; +import run.halo.app.content.TestPost; +import run.halo.app.core.extension.Post; + +/** + * Tests for @{@link PostEndpoint}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PostEndpointTest { + @Mock + private PostService postService; + + private WebTestClient webTestClient; + + @BeforeEach + void setUp() { + PostEndpoint postEndpoint = new PostEndpoint(postService); + + webTestClient = WebTestClient + .bindToRouterFunction(postEndpoint.endpoint()) + .build(); + } + + @Test + void draftPost() { + when(postService.draftPost(any())).thenReturn(Mono.just(TestPost.postV1())); + + webTestClient.post() + .uri("/posts") + .bodyValue(postRequest(TestPost.postV1())) + .exchange() + .expectStatus() + .isOk() + .expectBody(Post.class) + .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); + } + + @Test + void updatePost() { + when(postService.updatePost(any())).thenReturn(Mono.just(TestPost.postV1())); + + webTestClient.put() + .uri("/posts/post-A") + .bodyValue(postRequest(TestPost.postV1())) + .exchange() + .expectStatus() + .isOk() + .expectBody(Post.class) + .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); + } + + @Test + void publishPost() { + when(postService.publishPost(any())).thenReturn(Mono.just(TestPost.postV1())); + + webTestClient.put() + .uri("/posts/post-A/publish") + .bodyValue(postRequest(TestPost.postV1())) + .exchange() + .expectStatus() + .isOk() + .expectBody(Post.class) + .value(post -> assertThat(post).isEqualTo(TestPost.postV1())); + } + + PostRequest postRequest(Post post) { + return new PostRequest(post, new PostRequest.Content("B", "

B

", "MARKDOWN")); + } +} \ No newline at end of file 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 new file mode 100644 index 000000000..902b965c3 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/reconciler/PostReconcilerTest.java @@ -0,0 +1,81 @@ +package run.halo.app.core.extension.reconciler; + +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.util.List; +import java.util.Optional; +import java.util.Set; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.content.ContentService; +import run.halo.app.content.ContentWrapper; +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.ExtensionClient; +import run.halo.app.extension.controller.Reconciler; + +/** + * Tests for {@link PostReconciler}. + * + * @author guqing + * @since 2.0.0 + */ +@ExtendWith(MockitoExtension.class) +class PostReconcilerTest { + + @Mock + private ExtensionClient client; + @Mock + private ContentService contentService; + + private PostReconciler postReconciler; + + @BeforeEach + void setUp() { + postReconciler = new PostReconciler(client, contentService); + } + + @Test + void reconcile() { + String name = "post-A"; + Post post = TestPost.postV1(); + post.getSpec().setHeadSnapshot("post-A-head-snapshot"); + when(client.fetch(eq(Post.class), eq(name))) + .thenReturn(Optional.of(post)); + when(contentService.getContent(eq(post.getSpec().getHeadSnapshot()))) + .thenReturn(Mono.just( + new ContentWrapper(post.getSpec().getHeadSnapshot(), "hello world", + "

hello world

", "markdown"))); + + Snapshot snapshotV1 = TestPost.snapshotV1(); + Snapshot snapshotV2 = TestPost.snapshotV2(); + snapshotV1.getSpec().setContributors(Set.of("guqing")); + snapshotV2.getSpec().setContributors(Set.of("guqing", "zhangsan")); + when(contentService.listSnapshots(any())) + .thenReturn(Flux.just(snapshotV1, snapshotV2)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Post.class); + postReconciler.reconcile(new Reconciler.Request(name)); + + verify(client, times(1)).update(captor.capture()); + + Post value = captor.getValue(); + assertThat(value.getStatus().getExcerpt()).isEqualTo("hello world"); + assertThat(value.getStatus().getPermalink()).isEqualTo( + PostReconciler.PERMALINK_PREFIX + name); + assertThat(value.getStatus().getContributors()).isEqualTo(List.of("guqing", "zhangsan")); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/ListResultTest.java b/src/test/java/run/halo/app/extension/ListResultTest.java index c47db21b9..b64f59348 100644 --- a/src/test/java/run/halo/app/extension/ListResultTest.java +++ b/src/test/java/run/halo/app/extension/ListResultTest.java @@ -19,4 +19,12 @@ class ListResultTest { assertEquals("FakeList", fakeListClass.getSimpleName()); } + @Test + void generateGenericClassForClassParam() { + var fakeListClass = ListResult.generateGenericClass(FakeExtension.class); + assertTrue(ListResult.class.isAssignableFrom(fakeListClass)); + assertSame(FakeExtension.class, ((ParameterizedType) fakeListClass.getGenericSuperclass()) + .getActualTypeArguments()[0]); + assertEquals("FakeExtensionList", fakeListClass.getSimpleName()); + } } \ No newline at end of file