perf: optimize snapshot queries for post and single page (#3168)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.2.x
/kind api-change

#### What this PR does / why we need it:
优化文章和自定义页面的内容查询

- 文章新增 `GET /posts/{name}/release-content` 和 `GET /posts/{name}/head-content` 自定义 APIs
- 自定义页面新增 `GET /posts/{name}/release-content` 和 `GET /posts/{name}/head-content` 自定义 APIs

⚠️ 移除了 ContentEndpoint,如果 Console 调用过 `/apis/api.console.halo.run/contents/{name}` 查询内容则需要用文章或自定义页面的自定义APIs 替代

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

Fixes #3140
Fixes https://github.com/halo-dev/halo/issues/3026

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
优化文章和自定义页面的内容查询
```
pull/3185/head
guqing 2023-01-30 15:18:15 +08:00 committed by GitHub
parent 1d4f65c0cf
commit 3bd0a09764
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 384 additions and 802 deletions

View File

@ -0,0 +1,142 @@
package run.halo.app.content;
import java.security.Principal;
import java.time.Instant;
import java.util.UUID;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.lang.Nullable;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.util.Assert;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
/**
* Abstract Service for {@link Snapshot}.
*
* @author guqing
* @since 2.0.0
*/
@AllArgsConstructor
public abstract class AbstractContentService {
private final ReactiveExtensionClient client;
public Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName) {
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.flatMap(baseSnapshot -> {
if (StringUtils.equals(snapshotName, baseSnapshotName)) {
return Mono.just(baseSnapshot.applyPatch(baseSnapshot));
}
return client.fetch(Snapshot.class, snapshotName)
.map(snapshot -> snapshot.applyPatch(baseSnapshot));
});
}
protected void checkBaseSnapshot(Snapshot snapshot) {
Assert.notNull(snapshot, "The snapshot must not be null.");
String keepRawAnno =
ExtensionUtil.nullSafeAnnotations(snapshot).get(Snapshot.KEEP_RAW_ANNO);
if (!org.thymeleaf.util.StringUtils.equals(Boolean.TRUE.toString(), keepRawAnno)) {
throw new IllegalArgumentException(
String.format("The snapshot [%s] is not a base snapshot.",
snapshot.getMetadata().getName()));
}
}
protected Mono<ContentWrapper> draftContent(@Nullable String baseSnapshotName,
ContentRequest contentRequest,
@Nullable String parentSnapshotName) {
Snapshot snapshot = contentRequest.toSnapshot();
snapshot.getMetadata().setName(UUID.randomUUID().toString());
snapshot.getSpec().setParentSnapshotName(parentSnapshotName);
final String baseSnapshotNameToUse =
StringUtils.defaultString(baseSnapshotName, snapshot.getMetadata().getName());
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.defaultIfEmpty(snapshot)
.map(baseSnapshot -> determineRawAndContentPatch(snapshot, baseSnapshot,
contentRequest)
)
.flatMap(source -> getContextUsername()
.map(username -> {
Snapshot.addContributor(source, username);
source.getSpec().setOwner(username);
return source;
})
.defaultIfEmpty(source)
)
.flatMap(snapshotToCreate -> client.create(snapshotToCreate)
.flatMap(head -> restoredContent(baseSnapshotNameToUse, head)));
}
protected Mono<ContentWrapper> draftContent(String baseSnapshotName, ContentRequest content) {
return this.draftContent(baseSnapshotName, content, content.headSnapshotName());
}
protected Mono<ContentWrapper> updateContent(String baseSnapshotName,
ContentRequest contentRequest) {
Assert.notNull(contentRequest, "The contentRequest must not be null");
Assert.notNull(baseSnapshotName, "The baseSnapshotName must not be null");
Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
return client.fetch(Snapshot.class, contentRequest.headSnapshotName())
.flatMap(headSnapshot -> client.fetch(Snapshot.class, baseSnapshotName)
.map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot,
contentRequest)
)
)
.flatMap(headSnapshot -> getContextUsername()
.map(username -> {
Snapshot.addContributor(headSnapshot, username);
return headSnapshot;
})
.defaultIfEmpty(headSnapshot)
)
.flatMap(client::update)
.flatMap(head -> restoredContent(baseSnapshotName, head));
}
protected Mono<ContentWrapper> restoredContent(String baseSnapshotName, Snapshot headSnapshot) {
return client.fetch(Snapshot.class, baseSnapshotName)
.doOnNext(this::checkBaseSnapshot)
.map(headSnapshot::applyPatch);
}
protected Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot,
ContentRequest contentRequest) {
Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
Assert.notNull(contentRequest, "The contentRequest must not be null.");
Assert.notNull(snapshotToUse, "The snapshotToUse not be null.");
String originalRaw = baseSnapshot.getSpec().getRawPatch();
String originalContent = baseSnapshot.getSpec().getContentPatch();
String baseSnapshotName = baseSnapshot.getMetadata().getName();
snapshotToUse.getSpec().setLastModifyTime(Instant.now());
// it is the v1 snapshot, set the content directly
if (org.thymeleaf.util.StringUtils.equals(baseSnapshotName,
snapshotToUse.getMetadata().getName())) {
snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
snapshotToUse.getSpec().setContentPatch(contentRequest.content());
ExtensionUtil.nullSafeAnnotations(snapshotToUse)
.put(Snapshot.KEEP_RAW_ANNO, Boolean.TRUE.toString());
} else {
// otherwise diff a patch based on the v1 snapshot
String revisedRaw = contentRequest.rawPatchFrom(originalRaw);
String revisedContent = contentRequest.contentPatchFrom(originalContent);
snapshotToUse.getSpec().setRawPatch(revisedRaw);
snapshotToUse.getSpec().setContentPatch(revisedContent);
}
return snapshotToUse;
}
protected Mono<String> getContextUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
}
}

View File

@ -1,29 +0,0 @@
package run.halo.app.content;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.Ref;
/**
* Content service for {@link Snapshot}.
*
* @author guqing
* @since 2.0.0
*/
public interface ContentService {
Mono<ContentWrapper> getContent(String name);
Mono<ContentWrapper> draftContent(ContentRequest content);
Mono<ContentWrapper> draftContent(ContentRequest content, String parentName);
Mono<ContentWrapper> updateContent(ContentRequest content);
Mono<Snapshot> getBaseSnapshot(Ref subjectRef);
Mono<Snapshot> latestSnapshotVersion(Ref subjectRef);
Flux<Snapshot> listSnapshots(Ref subjectRef);
}

View File

@ -17,4 +17,10 @@ public interface PostService {
Mono<Post> draftPost(PostRequest postRequest);
Mono<Post> updatePost(PostRequest postRequest);
Mono<ContentWrapper> getHeadContent(String postName);
Mono<ContentWrapper> getReleaseContent(String postName);
Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName);
}

View File

@ -12,6 +12,12 @@ import run.halo.app.extension.ListResult;
*/
public interface SinglePageService {
Mono<ContentWrapper> getHeadContent(String singlePageName);
Mono<ContentWrapper> getReleaseContent(String singlePageName);
Mono<ContentWrapper> getContent(String snapshotName, String baseSnapshotName);
Mono<ListResult<ListedSinglePage>> list(SinglePageQuery listRequest);
Mono<SinglePage> draft(SinglePageRequest pageRequest);

View File

@ -1,164 +0,0 @@
package run.halo.app.content.impl;
import java.security.Principal;
import java.time.Instant;
import java.util.Comparator;
import java.util.UUID;
import java.util.function.Function;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import org.thymeleaf.util.StringUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentService;
import run.halo.app.content.ContentWrapper;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* A default implementation of {@link ContentService}.
*
* @author guqing
* @since 2.0.0
*/
@Component
public class ContentServiceImpl implements ContentService {
private final ReactiveExtensionClient client;
public ContentServiceImpl(ReactiveExtensionClient client) {
this.client = client;
}
@Override
public Mono<ContentWrapper> getContent(String name) {
return client.fetch(Snapshot.class, name)
.flatMap(snapshot -> getBaseSnapshot(snapshot.getSpec().getSubjectRef())
.map(snapshot::applyPatch));
}
@Override
public Mono<ContentWrapper> draftContent(ContentRequest content) {
return this.draftContent(content, content.headSnapshotName());
}
@Override
public Mono<ContentWrapper> draftContent(ContentRequest contentRequest, String parentName) {
return Mono.defer(
() -> {
Snapshot snapshot = contentRequest.toSnapshot();
snapshot.getMetadata().setName(UUID.randomUUID().toString());
snapshot.getSpec().setParentSnapshotName(parentName);
return getBaseSnapshot(contentRequest.subjectRef())
.defaultIfEmpty(snapshot)
.map(baseSnapshot -> determineRawAndContentPatch(snapshot, baseSnapshot,
contentRequest))
.flatMap(source -> getContextUsername()
.map(username -> {
Snapshot.addContributor(source, username);
source.getSpec().setOwner(username);
return source;
})
.defaultIfEmpty(source)
);
})
.flatMap(snapshot -> client.create(snapshot)
.flatMap(this::restoredContent));
}
@Override
public Mono<ContentWrapper> updateContent(ContentRequest contentRequest) {
Assert.notNull(contentRequest, "The contentRequest must not be null");
Assert.notNull(contentRequest.headSnapshotName(), "The headSnapshotName must not be null");
Ref subjectRef = contentRequest.subjectRef();
return client.fetch(Snapshot.class, contentRequest.headSnapshotName())
.flatMap(headSnapshot -> getBaseSnapshot(subjectRef)
.map(baseSnapshot -> determineRawAndContentPatch(headSnapshot, baseSnapshot,
contentRequest)
)
)
.flatMap(headSnapshot -> getContextUsername()
.map(username -> {
Snapshot.addContributor(headSnapshot, username);
return headSnapshot;
})
.defaultIfEmpty(headSnapshot)
)
.flatMap(client::update)
.flatMap(this::restoredContent);
}
private Mono<ContentWrapper> restoredContent(Snapshot headSnapshot) {
return getBaseSnapshot(headSnapshot.getSpec().getSubjectRef())
.map(headSnapshot::applyPatch);
}
@Override
public Mono<Snapshot> getBaseSnapshot(Ref subjectRef) {
return listSnapshots(subjectRef)
.sort(createTimeReversedComparator().reversed())
.filter(p -> StringUtils.equals(Boolean.TRUE.toString(),
ExtensionUtil.nullSafeAnnotations(p).get(Snapshot.KEEP_RAW_ANNO)))
.next();
}
@Override
public Mono<Snapshot> latestSnapshotVersion(Ref subjectRef) {
Assert.notNull(subjectRef, "The subjectRef must not be null.");
return listSnapshots(subjectRef)
.sort(createTimeReversedComparator())
.next();
}
@Override
public Flux<Snapshot> listSnapshots(Ref subjectRef) {
Assert.notNull(subjectRef, "The subjectRef must not be null.");
return client.list(Snapshot.class, snapshot -> subjectRef.equals(snapshot.getSpec()
.getSubjectRef()), null);
}
private Mono<String> getContextUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
}
private Snapshot determineRawAndContentPatch(Snapshot snapshotToUse, Snapshot baseSnapshot,
ContentRequest contentRequest) {
Assert.notNull(baseSnapshot, "The baseSnapshot must not be null.");
Assert.notNull(contentRequest, "The contentRequest must not be null.");
Assert.notNull(snapshotToUse, "The snapshotToUse not be null.");
String originalRaw = baseSnapshot.getSpec().getRawPatch();
String originalContent = baseSnapshot.getSpec().getContentPatch();
String baseSnapshotName = baseSnapshot.getMetadata().getName();
snapshotToUse.getSpec().setLastModifyTime(Instant.now());
// it is the v1 snapshot, set the content directly
if (StringUtils.equals(baseSnapshotName, snapshotToUse.getMetadata().getName())) {
snapshotToUse.getSpec().setRawPatch(contentRequest.raw());
snapshotToUse.getSpec().setContentPatch(contentRequest.content());
ExtensionUtil.nullSafeAnnotations(snapshotToUse)
.put(Snapshot.KEEP_RAW_ANNO, Boolean.TRUE.toString());
} else {
// otherwise diff a patch based on the v1 snapshot
String revisedRaw = contentRequest.rawPatchFrom(originalRaw);
String revisedContent = contentRequest.contentPatchFrom(originalContent);
snapshotToUse.getSpec().setRawPatch(revisedRaw);
snapshotToUse.getSpec().setContentPatch(revisedContent);
}
return snapshotToUse;
}
Comparator<Snapshot> createTimeReversedComparator() {
Function<Snapshot, String> name = snapshot -> snapshot.getMetadata().getName();
Function<Snapshot, Instant> createTime = snapshot -> snapshot.getMetadata()
.getCreationTimestamp();
return Comparator.comparing(createTime)
.thenComparing(name)
.reversed();
}
}

View File

@ -2,7 +2,6 @@ package run.halo.app.content.impl;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
@ -11,19 +10,16 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
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 reactor.util.retry.Retry;
import run.halo.app.content.AbstractContentService;
import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentService;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.Contributor;
import run.halo.app.content.ListedPost;
@ -52,12 +48,16 @@ import run.halo.app.metrics.MeterUtils;
*/
@Slf4j
@Component
@AllArgsConstructor
public class PostServiceImpl implements PostService {
private final ContentService contentService;
public class PostServiceImpl extends AbstractContentService implements PostService {
private final ReactiveExtensionClient client;
private final CounterService counterService;
public PostServiceImpl(ReactiveExtensionClient client, CounterService counterService) {
super(client);
this.client = client;
this.counterService = counterService;
}
@Override
public Mono<ListResult<ListedPost>> listPost(PostQuery query) {
Comparator<Post> comparator =
@ -253,7 +253,7 @@ public class PostServiceImpl implements PostService {
new ContentRequest(Ref.of(post), post.getSpec().getHeadSnapshot(),
postRequest.content().raw(), postRequest.content().content(),
postRequest.content().rawType());
return contentService.draftContent(contentRequest)
return draftContent(post.getSpec().getBaseSnapshot(), contentRequest)
.flatMap(contentWrapper -> waitForPostToDraftConcludingWork(
post.getMetadata().getName(),
contentWrapper)
@ -293,16 +293,17 @@ public class PostServiceImpl implements PostService {
Post post = postRequest.post();
String headSnapshot = post.getSpec().getHeadSnapshot();
String releaseSnapshot = post.getSpec().getReleaseSnapshot();
String baseSnapshot = post.getSpec().getBaseSnapshot();
if (StringUtils.equals(releaseSnapshot, headSnapshot)) {
// create new snapshot to update first
return contentService.draftContent(postRequest.contentRequest(), headSnapshot)
return draftContent(baseSnapshot, postRequest.contentRequest(), headSnapshot)
.flatMap(contentWrapper -> {
post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
return client.update(post);
});
}
return contentService.updateContent(postRequest.contentRequest())
return updateContent(baseSnapshot, postRequest.contentRequest())
.flatMap(contentWrapper -> {
post.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
return client.update(post);
@ -311,9 +312,21 @@ public class PostServiceImpl implements PostService {
.filter(throwable -> throwable instanceof OptimisticLockingFailureException));
}
private Mono<String> getContextUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
@Override
public Mono<ContentWrapper> getHeadContent(String postName) {
return client.get(Post.class, postName)
.flatMap(post -> {
String headSnapshot = post.getSpec().getHeadSnapshot();
return getContent(headSnapshot, post.getSpec().getBaseSnapshot());
});
}
@Override
public Mono<ContentWrapper> getReleaseContent(String postName) {
return client.get(Post.class, postName)
.flatMap(post -> {
String releaseSnapshot = post.getSpec().getReleaseSnapshot();
return getContent(releaseSnapshot, post.getSpec().getBaseSnapshot());
});
}
}

View File

@ -2,7 +2,6 @@ package run.halo.app.content.impl;
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
import java.security.Principal;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
@ -11,19 +10,16 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.AbstractContentService;
import run.halo.app.content.ContentRequest;
import run.halo.app.content.ContentService;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.Contributor;
import run.halo.app.content.ListedSinglePage;
@ -51,14 +47,35 @@ import run.halo.app.metrics.MeterUtils;
*/
@Slf4j
@Service
@AllArgsConstructor
public class SinglePageServiceImpl implements SinglePageService {
private final ContentService contentService;
public class SinglePageServiceImpl extends AbstractContentService implements SinglePageService {
private final ReactiveExtensionClient client;
private final CounterService counterService;
public SinglePageServiceImpl(ReactiveExtensionClient client, CounterService counterService) {
super(client);
this.client = client;
this.counterService = counterService;
}
@Override
public Mono<ContentWrapper> getHeadContent(String singlePageName) {
return client.get(SinglePage.class, singlePageName)
.flatMap(singlePage -> {
String headSnapshot = singlePage.getSpec().getHeadSnapshot();
return getContent(headSnapshot, singlePage.getSpec().getBaseSnapshot());
});
}
@Override
public Mono<ContentWrapper> getReleaseContent(String singlePageName) {
return client.get(SinglePage.class, singlePageName)
.flatMap(singlePage -> {
String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot();
return getContent(releaseSnapshot, singlePage.getSpec().getBaseSnapshot());
});
}
@Override
public Mono<ListResult<ListedSinglePage>> list(SinglePageQuery query) {
Comparator<SinglePage> comparator =
@ -96,7 +113,7 @@ public class SinglePageServiceImpl implements SinglePageService {
new ContentRequest(Ref.of(page), page.getSpec().getHeadSnapshot(),
pageRequest.content().raw(), pageRequest.content().content(),
pageRequest.content().rawType());
return contentService.draftContent(contentRequest)
return draftContent(page.getSpec().getBaseSnapshot(), contentRequest)
.flatMap(
contentWrapper -> waitForPageToDraftConcludingWork(
page.getMetadata().getName(),
@ -137,16 +154,17 @@ public class SinglePageServiceImpl implements SinglePageService {
SinglePage page = pageRequest.page();
String headSnapshot = page.getSpec().getHeadSnapshot();
String releaseSnapshot = page.getSpec().getReleaseSnapshot();
String baseSnapshot = page.getSpec().getBaseSnapshot();
// create new snapshot to update first
if (StringUtils.equals(headSnapshot, releaseSnapshot)) {
return contentService.draftContent(pageRequest.contentRequest(), headSnapshot)
return draftContent(baseSnapshot, pageRequest.contentRequest(), headSnapshot)
.flatMap(contentWrapper -> {
page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
return client.update(page);
});
}
return contentService.updateContent(pageRequest.contentRequest())
return updateContent(baseSnapshot, pageRequest.contentRequest())
.flatMap(contentWrapper -> {
page.getSpec().setHeadSnapshot(contentWrapper.getSnapshotName());
return client.update(page);
@ -155,12 +173,6 @@ public class SinglePageServiceImpl implements SinglePageService {
.filter(throwable -> throwable instanceof OptimisticLockingFailureException));
}
private Mono<String> getContextUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Principal::getName);
}
Predicate<SinglePage> pageListPredicate(SinglePageQuery query) {
Predicate<SinglePage> paramPredicate = singlePage -> contains(query.getContributors(),
singlePage.getStatusOrDefault().getContributors());

View File

@ -1,141 +0,0 @@
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 io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
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;
/**
* 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<ServerResponse> endpoint() {
final var tag = "api.console.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(ContentResponse.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(ContentResponse.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(ContentResponse.class))
)
.build();
}
private Mono<ServerResponse> obtainContent(ServerRequest request) {
String snapshotName = request.pathVariable("snapshotName");
return contentService.getContent(snapshotName)
.map(ContentResponse::from)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> 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);
})
.map(ContentResponse::from)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> draftSnapshotContent(ServerRequest request) {
return request.bodyToMono(ContentRequest.class)
.flatMap(contentService::draftContent)
.map(ContentResponse::from)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
@Data
public static class ContentResponse {
@Schema(required = true, description = "The headSnapshotName if updated or new name if "
+ "created.")
private String snapshotName;
@Schema(required = true)
private String raw;
@Schema(required = true)
private String content;
@Schema(required = true)
private String rawType;
/**
* Converts content response from {@link ContentWrapper}.
*/
public static ContentResponse from(ContentWrapper wrapper) {
ContentResponse response = new ContentResponse();
response.raw = wrapper.getRaw();
response.setSnapshotName(wrapper.getSnapshotName());
response.content = wrapper.getContent();
response.rawType = wrapper.getRawType();
return response;
}
}
}

View File

@ -22,6 +22,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.util.StringUtils;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedPost;
import run.halo.app.content.PostQuery;
import run.halo.app.content.PostRequest;
@ -64,6 +65,30 @@ public class PostEndpoint implements CustomEndpoint {
QueryParamBuildUtil.buildParametersFromType(builder, PostQuery.class);
}
)
.GET("posts/{name}/head-content", this::fetchHeadContent,
builder -> builder.operationId("fetchPostHeadContent")
.description("Fetch head content of post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("posts/{name}/release-content", this::fetchReleaseContent,
builder -> builder.operationId("fetchPostReleaseContent")
.description("Fetch release content of post.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.POST("posts", this::draftPost,
builder -> builder.operationId("DraftPost")
.description("Draft a post.")
@ -148,6 +173,18 @@ public class PostEndpoint implements CustomEndpoint {
.build();
}
private Mono<ServerResponse> fetchReleaseContent(ServerRequest request) {
final var name = request.pathVariable("name");
return postService.getReleaseContent(name)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> fetchHeadContent(ServerRequest request) {
String name = request.pathVariable("name");
return postService.getHeadContent(name)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
Mono<ServerResponse> draftPost(ServerRequest request) {
return request.bodyToMono(PostRequest.class)
.flatMap(postService::draftPost)

View File

@ -20,6 +20,7 @@ import org.springframework.web.reactive.function.server.ServerResponse;
import org.thymeleaf.util.StringUtils;
import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.content.ContentWrapper;
import run.halo.app.content.ListedSinglePage;
import run.halo.app.content.SinglePageQuery;
import run.halo.app.content.SinglePageRequest;
@ -59,6 +60,30 @@ public class SinglePageEndpoint implements CustomEndpoint {
QueryParamBuildUtil.buildParametersFromType(builder, SinglePageQuery.class);
}
)
.GET("singlepages/{name}/head-content", this::fetchHeadContent,
builder -> builder.operationId("fetchSinglePageHeadContent")
.description("Fetch head content of single page.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.GET("singlepages/{name}/release-content", this::fetchReleaseContent,
builder -> builder.operationId("fetchSinglePageReleaseContent")
.description("Fetch release content of single page.")
.tag(tag)
.parameter(parameterBuilder().name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ContentWrapper.class))
)
.POST("singlepages", this::draftSinglePage,
builder -> builder.operationId("DraftSinglePage")
.description("Draft a single page.")
@ -123,6 +148,18 @@ public class SinglePageEndpoint implements CustomEndpoint {
.build();
}
private Mono<ServerResponse> fetchReleaseContent(ServerRequest request) {
final var name = request.pathVariable("name");
return singlePageService.getReleaseContent(name)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
private Mono<ServerResponse> fetchHeadContent(ServerRequest request) {
String name = request.pathVariable("name");
return singlePageService.getHeadContent(name)
.flatMap(content -> ServerResponse.ok().bodyValue(content));
}
Mono<ServerResponse> draftSinglePage(ServerRequest request) {
return request.bodyToMono(SinglePageRequest.class)
.flatMap(singlePageService::draft)

View File

@ -13,7 +13,7 @@ import org.jsoup.Jsoup;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.content.ContentService;
import run.halo.app.content.PostService;
import run.halo.app.content.permalinks.PostPermalinkPolicy;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
@ -52,7 +52,7 @@ import run.halo.app.metrics.MeterUtils;
public class PostReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER_NAME = "post-protection";
private final ExtensionClient client;
private final ContentService contentService;
private final PostService postService;
private final PostPermalinkPolicy postPermalinkPolicy;
private final CounterService counterService;
@ -269,7 +269,7 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
spec.setExcerpt(excerpt);
}
if (excerpt.getAutoGenerate()) {
contentService.getContent(spec.getReleaseSnapshot())
postService.getContent(spec.getReleaseSnapshot(), spec.getBaseSnapshot())
.blockOptional()
.ifPresent(content -> {
String contentRevised = content.getContent();
@ -282,11 +282,13 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
Ref ref = Ref.of(post);
// handle contributors
String headSnapshot = post.getSpec().getHeadSnapshot();
contentService.listSnapshots(ref)
.collectList()
.blockOptional()
.ifPresent(snapshots -> {
List<String> contributors = snapshots.stream()
List<String> contributors = client.list(Snapshot.class,
snapshot -> ref.equals(snapshot.getSpec().getSubjectRef()), null)
.stream()
.peek(snapshot -> {
snapshot.getSpec().setContentPatch(StringUtils.EMPTY);
snapshot.getSpec().setRawPatch(StringUtils.EMPTY);
})
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
@ -301,7 +303,6 @@ public class PostReconciler implements Reconciler<Reconciler.Request> {
// update in progress status
status.setInProgress(
!StringUtils.equals(headSnapshot, post.getSpec().getReleaseSnapshot()));
});
if (post.isPublished() && status.getLastModifyTime() == null) {
client.fetch(Snapshot.class, post.getSpec().getReleaseSnapshot())

View File

@ -17,7 +17,7 @@ import org.jsoup.Jsoup;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import run.halo.app.content.ContentService;
import run.halo.app.content.SinglePageService;
import run.halo.app.content.permalinks.ExtensionLocator;
import run.halo.app.core.extension.content.Comment;
import run.halo.app.core.extension.content.Post;
@ -60,7 +60,7 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
private static final String FINALIZER_NAME = "single-page-protection";
private static final GroupVersionKind GVK = GroupVersionKind.fromExtension(SinglePage.class);
private final ExtensionClient client;
private final ContentService contentService;
private final SinglePageService singlePageService;
private final ApplicationContext applicationContext;
private final CounterService counterService;
@ -337,7 +337,7 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
}
if (excerpt.getAutoGenerate()) {
contentService.getContent(spec.getHeadSnapshot())
singlePageService.getContent(spec.getHeadSnapshot(), spec.getBaseSnapshot())
.blockOptional()
.ifPresent(content -> {
String contentRevised = content.getContent();
@ -349,10 +349,13 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
// handle contributors
String headSnapshot = singlePage.getSpec().getHeadSnapshot();
contentService.listSnapshots(Ref.of(singlePage))
.collectList()
.blockOptional().ifPresent(snapshots -> {
List<String> contributors = snapshots.stream()
List<String> contributors = client.list(Snapshot.class,
snapshot -> Ref.of(singlePage).equals(snapshot.getSpec().getSubjectRef()), null)
.stream()
.peek(snapshot -> {
snapshot.getSpec().setContentPatch(StringUtils.EMPTY);
snapshot.getSpec().setRawPatch(StringUtils.EMPTY);
})
.map(snapshot -> {
Set<String> usernames = snapshot.getSpec().getContributors();
return Objects.requireNonNullElseGet(usernames,
@ -367,7 +370,6 @@ public class SinglePageReconciler implements Reconciler<Reconciler.Request> {
// update in progress status
String releaseSnapshot = singlePage.getSpec().getReleaseSnapshot();
status.setInProgress(!StringUtils.equals(releaseSnapshot, headSnapshot));
});
if (singlePage.isPublished() && status.getLastModifyTime() == null) {
client.fetch(Snapshot.class, singlePage.getSpec().getReleaseSnapshot())

View File

@ -11,6 +11,7 @@ import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.tuple.Pair;
@ -20,7 +21,7 @@ import org.springframework.util.comparator.Comparators;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.util.function.Tuple2;
import run.halo.app.content.ContentService;
import run.halo.app.content.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
@ -47,6 +48,7 @@ import run.halo.app.theme.finders.vo.StatsVo;
* @since 2.0.0
*/
@Finder("postFinder")
@AllArgsConstructor
public class PostFinderImpl implements PostFinder {
public static final Predicate<Post> FIXED_PREDICATE = post -> post.isPublished()
@ -54,7 +56,7 @@ public class PostFinderImpl implements PostFinder {
&& Post.VisibleEnum.PUBLIC.equals(post.getSpec().getVisible());
private final ReactiveExtensionClient client;
private final ContentService contentService;
private final PostService postService;
private final TagFinder tagFinder;
@ -64,19 +66,6 @@ public class PostFinderImpl implements PostFinder {
private final CounterService counterService;
public PostFinderImpl(ReactiveExtensionClient client,
ContentService contentService,
TagFinder tagFinder,
CategoryFinder categoryFinder,
ContributorFinder contributorFinder, CounterService counterService) {
this.client = client;
this.contentService = contentService;
this.tagFinder = tagFinder;
this.categoryFinder = categoryFinder;
this.contributorFinder = contributorFinder;
this.counterService = counterService;
}
@Override
public Mono<PostVo> getByName(String postName) {
return client.fetch(Post.class, postName)
@ -90,9 +79,7 @@ public class PostFinderImpl implements PostFinder {
@Override
public Mono<ContentVo> content(String postName) {
return client.fetch(Post.class, postName)
.map(post -> post.getSpec().getReleaseSnapshot())
.flatMap(contentService::getContent)
return postService.getReleaseContent(postName)
.map(wrapper -> ContentVo.builder().content(wrapper.getContent())
.raw(wrapper.getRaw()).build());
}

View File

@ -6,11 +6,12 @@ import java.util.List;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.util.CollectionUtils;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.content.ContentService;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.ListResult;
@ -32,6 +33,7 @@ import run.halo.app.theme.finders.vo.StatsVo;
* @since 2.0.0
*/
@Finder("singlePageFinder")
@AllArgsConstructor
public class SinglePageFinderImpl implements SinglePageFinder {
public static final Predicate<SinglePage> FIXED_PREDICATE = page -> page.isPublished()
@ -40,20 +42,12 @@ public class SinglePageFinderImpl implements SinglePageFinder {
private final ReactiveExtensionClient client;
private final ContentService contentService;
private final SinglePageService singlePageService;
private final ContributorFinder contributorFinder;
private final CounterService counterService;
public SinglePageFinderImpl(ReactiveExtensionClient client, ContentService contentService,
ContributorFinder contributorFinder, CounterService counterService) {
this.client = client;
this.contentService = contentService;
this.contributorFinder = contributorFinder;
this.counterService = counterService;
}
@Override
public Mono<SinglePageVo> getByName(String pageName) {
return client.fetch(SinglePage.class, pageName)
@ -80,9 +74,7 @@ public class SinglePageFinderImpl implements SinglePageFinder {
@Override
public Mono<ContentVo> content(String pageName) {
return client.fetch(SinglePage.class, pageName)
.map(page -> page.getSpec().getReleaseSnapshot())
.flatMap(contentService::getContent)
return singlePageService.getReleaseContent(pageName)
.map(wrapper -> ContentVo.builder().content(wrapper.getContent())
.raw(wrapper.getRaw()).build());
}

View File

@ -16,7 +16,7 @@ rules:
resources: [ "posts" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content", "contents", "contents/publish" ]
resources: [ "posts", "posts/publish", "posts/unpublish", "posts/recycle", "posts/content" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
---
apiVersion: v1alpha1
@ -37,5 +37,5 @@ rules:
resources: [ "posts" ]
verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "posts", "contents" ]
resources: [ "posts", "posts/head-content", "posts/release-content" ]
verbs: [ "get", "list" ]

View File

@ -15,7 +15,7 @@ rules:
resources: [ "singlepages" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "singlepages", "singlepages/publish", "singlepages/content", "contents", "contents/publish" ]
resources: [ "singlepages", "singlepages/publish", "singlepages/content" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
---
apiVersion: v1alpha1
@ -35,5 +35,5 @@ rules:
resources: [ "singlepages" ]
verbs: [ "get", "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "singlepages", "contents" ]
resources: [ "singlepages", "singlepages/head-content", "singlepages/release-content" ]
verbs: [ "get", "list" ]

View File

@ -1,240 +0,0 @@
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.util.HashMap;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
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.content.Post;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* 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();
Ref ref = postRef("test-post");
snapshotV1.getSpec().setSubjectRef(ref);
ContentRequest contentRequest =
new ContentRequest(ref, null,
snapshotV1.getSpec().getRawPatch(),
snapshotV1.getSpec().getContentPatch(),
snapshotV1.getSpec().getRawType());
pilingBaseSnapshot(snapshotV1);
ContentWrapper contentWrapper = ContentWrapper.builder()
.snapshotName("snapshot-A")
.raw(contentRequest.raw())
.content(contentRequest.content())
.rawType(snapshotV1.getSpec().getRawType())
.build();
ArgumentCaptor<Snapshot> 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();
assertThat(snapshot.getMetadata().getName())
.isNotEqualTo(snapshotV1.getMetadata().getName());
assertThat(snapshot.getSpec().getLastModifyTime()).isNotNull();
assertThat(snapshot.getSpec().getOwner()).isEqualTo("guqing");
assertThat(snapshot.getSpec().getContributors()).isEqualTo(Set.of("guqing"));
assertThat(snapshot.getSpec().getSubjectRef()).isEqualTo(ref);
}
@Test
void updateContent() {
String headSnapshot = "snapshot-A";
Snapshot snapshotV1 = snapshotV1();
Ref ref = postRef("test-post");
Snapshot updated = snapshotV1();
updated.getSpec().setRawPatch("hello");
updated.getSpec().setContentPatch("<p>hello</p>");
updated.getSpec().setSubjectRef(ref);
ContentRequest contentRequest =
new ContentRequest(ref, 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 = ContentWrapper.builder()
.snapshotName(headSnapshot)
.raw(contentRequest.raw())
.content(contentRequest.content())
.rawType(snapshotV1.getSpec().getRawType())
.build();
ArgumentCaptor<Snapshot> 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 updateContentWhenHeadPoints2Published() {
final Ref ref = postRef("test-post");
// v1(released),v2
Snapshot snapshotV1 = snapshotV1();
snapshotV1.getMetadata().setLabels(new HashMap<>());
ExtensionUtil.nullSafeAnnotations(snapshotV1)
.put(Snapshot.KEEP_RAW_ANNO, "true");
snapshotV1.getSpec().setSubjectRef(ref);
Snapshot snapshotV2 = snapshotV2();
snapshotV2.getSpec().setSubjectRef(ref);
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(ref, headSnapshot, "C",
"<p>C</p>", snapshotV1.getSpec().getRawType());
when(client.update(any())).thenReturn(Mono.just(snapshotV2));
StepVerifier.create(contentService.latestSnapshotVersion(ref))
.expectNext(snapshotV2)
.expectComplete()
.verify();
StepVerifier.create(contentService.updateContent(contentRequest))
.consumeNextWith(updated -> {
assertThat(updated.getRaw()).isEqualTo("C");
assertThat(updated.getContent()).isEqualTo("<p>C</p>");
})
.expectComplete()
.verify();
verify(client, times(1)).update(any());
}
private static Ref postRef(String name) {
Ref ref = new Ref();
ref.setGroup("content.halo.run");
ref.setVersion("v1alpha1");
ref.setKind(Post.KIND);
ref.setName(name);
return ref;
}
private void pilingBaseSnapshot(Snapshot... expected) {
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(expected));
}
@Test
void baseSnapshotVersion() {
String postName = "post-1";
final Ref ref = postRef(postName);
Snapshot snapshotV1 = snapshotV1();
snapshotV1.getMetadata().setLabels(new HashMap<>());
snapshotV1.getSpec().setSubjectRef(ref);
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV2.getSpec().setSubjectRef(ref);
Snapshot snapshotV3 = TestPost.snapshotV3();
snapshotV3.getSpec().setSubjectRef(ref);
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3));
StepVerifier.create(contentService.getBaseSnapshot(ref))
.expectNext(snapshotV1)
.expectComplete()
.verify();
}
@Test
void latestSnapshotVersion() {
String postName = "post-1";
final Ref ref = postRef(postName);
Snapshot snapshotV1 = snapshotV1();
snapshotV1.getMetadata().setLabels(new HashMap<>());
snapshotV1.getSpec().setSubjectRef(ref);
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV2.getSpec().setSubjectRef(ref);
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(snapshotV1, snapshotV2));
StepVerifier.create(contentService.latestSnapshotVersion(ref))
.expectNext(snapshotV2)
.expectComplete()
.verify();
Snapshot snapshotV3 = snapshotV3();
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(snapshotV1, snapshotV2, snapshotV3));
StepVerifier.create(contentService.latestSnapshotVersion(ref))
.expectNext(snapshotV3)
.expectComplete()
.verify();
}
}

View File

@ -1,73 +0,0 @@
package run.halo.app.content.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Flux;
import reactor.test.StepVerifier;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Snapshot;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Ref;
/**
* Tests for {@link ContentServiceImpl}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class ContentServiceImplTest {
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private ContentServiceImpl contentService;
@Test
void getBaseSnapshot() {
Snapshot snapshotV1 = TestPost.snapshotV1();
ExtensionUtil.nullSafeAnnotations(snapshotV1)
.put(Snapshot.KEEP_RAW_ANNO, "true");
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(TestPost.snapshotV2(), snapshotV1, TestPost.snapshotV3()));
contentService.getBaseSnapshot(Ref.of("fake-post"))
.as(StepVerifier::create)
.consumeNextWith(
baseSnapshot -> assertThat(baseSnapshot.getMetadata().getName())
.isEqualTo(snapshotV1.getMetadata().getName()))
.verifyComplete();
}
@Test
void latestSnapshotVersion() {
Snapshot snapshotV1 = TestPost.snapshotV1();
snapshotV1.getMetadata().setCreationTimestamp(Instant.now());
Snapshot snapshotV2 = TestPost.snapshotV2();
snapshotV2.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(2));
Snapshot snapshotV3 = TestPost.snapshotV3();
snapshotV3.getMetadata().setCreationTimestamp(Instant.now().plusSeconds(3));
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(Flux.just(snapshotV2, snapshotV1, snapshotV3));
contentService.latestSnapshotVersion(Ref.of("fake-post"))
.as(StepVerifier::create)
.consumeNextWith(s -> {
assertThat(s.getMetadata().getName()).isEqualTo(snapshotV3.getMetadata().getName());
})
.verifyComplete();
}
}

View File

@ -12,7 +12,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import run.halo.app.content.ContentService;
import run.halo.app.content.PostQuery;
import run.halo.app.content.TestPost;
import run.halo.app.core.extension.content.Post;
@ -29,9 +28,6 @@ class PostServiceImplTest {
@Mock
private ReactiveExtensionClient client;
@Mock
private ContentService contentService;
@InjectMocks
private PostServiceImpl postService;

View File

@ -20,9 +20,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
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.PostService;
import run.halo.app.content.TestPost;
@ -44,8 +42,7 @@ class PostReconcilerTest {
@Mock
private ExtensionClient client;
@Mock
private ContentService contentService;
@Mock
private PostPermalinkPolicy postPermalinkPolicy;
@ -66,15 +63,16 @@ class PostReconcilerTest {
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().getReleaseSnapshot())))
when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()),
eq(post.getSpec().getBaseSnapshot())))
.thenReturn(Mono.empty());
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));
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
@ -101,7 +99,8 @@ class PostReconcilerTest {
post.getSpec().setReleaseSnapshot("post-fake-released-snapshot");
when(client.fetch(eq(Post.class), eq(name)))
.thenReturn(Optional.of(post));
when(contentService.getContent(eq(post.getSpec().getReleaseSnapshot())))
when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()),
eq(post.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(post.getSpec().getHeadSnapshot())
.raw("hello world")
@ -116,8 +115,8 @@ class PostReconcilerTest {
Snapshot snapshotV1 = TestPost.snapshotV1();
snapshotV1.getSpec().setContributors(Set.of("guqing"));
when(contentService.listSnapshots(any()))
.thenReturn(Flux.just(snapshotV1, snapshotV2));
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
@ -138,7 +137,8 @@ class PostReconcilerTest {
post.getSpec().setReleaseSnapshot("post-fake-released-snapshot");
when(client.fetch(eq(Post.class), eq(name)))
.thenReturn(Optional.of(post));
when(contentService.getContent(eq(post.getSpec().getReleaseSnapshot())))
when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()),
eq(post.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(post.getSpec().getHeadSnapshot())
.raw("hello world")
@ -151,8 +151,8 @@ class PostReconcilerTest {
when(client.fetch(eq(Snapshot.class), eq(post.getSpec().getReleaseSnapshot())))
.thenReturn(Optional.of(snapshotV2));
when(contentService.listSnapshots(any()))
.thenReturn(Flux.empty());
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));
@ -171,7 +171,8 @@ class PostReconcilerTest {
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().getReleaseSnapshot())))
when(postService.getContent(eq(post.getSpec().getReleaseSnapshot()),
eq(post.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(post.getSpec().getHeadSnapshot())
.raw("hello world")
@ -179,8 +180,8 @@ class PostReconcilerTest {
.rawType("markdown")
.build()));
when(contentService.listSnapshots(any()))
.thenReturn(Flux.empty());
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<Post> captor = ArgumentCaptor.forClass(Post.class);
postReconciler.reconcile(new Reconciler.Request(name));

View File

@ -22,9 +22,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationContext;
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.SinglePageService;
import run.halo.app.content.TestPost;
@ -50,8 +48,6 @@ import run.halo.app.theme.router.PermalinkIndexUpdateCommand;
class SinglePageReconcilerTest {
@Mock
private ExtensionClient client;
@Mock
private ContentService contentService;
@Mock
private ApplicationContext applicationContext;
@ -75,7 +71,8 @@ class SinglePageReconcilerTest {
page.getSpec().setHeadSnapshot("page-A-head-snapshot");
when(client.fetch(eq(SinglePage.class), eq(name)))
.thenReturn(Optional.of(page));
when(contentService.getContent(eq(page.getSpec().getHeadSnapshot())))
when(singlePageService.getContent(eq(page.getSpec().getHeadSnapshot()),
eq(page.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(page.getSpec().getHeadSnapshot())
.raw("hello world")
@ -88,8 +85,8 @@ class SinglePageReconcilerTest {
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));
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of(snapshotV1, snapshotV2));
when(externalUrlSupplier.get()).thenReturn(URI.create(""));
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);
@ -138,7 +135,8 @@ class SinglePageReconcilerTest {
page.getSpec().setReleaseSnapshot("page-fake-released-snapshot");
when(client.fetch(eq(SinglePage.class), eq(name)))
.thenReturn(Optional.of(page));
when(contentService.getContent(eq(page.getSpec().getHeadSnapshot())))
when(singlePageService.getContent(eq(page.getSpec().getHeadSnapshot()),
eq(page.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(page.getSpec().getHeadSnapshot())
.raw("hello world")
@ -152,8 +150,8 @@ class SinglePageReconcilerTest {
when(client.fetch(eq(Snapshot.class), eq(page.getSpec().getReleaseSnapshot())))
.thenReturn(Optional.of(snapshotV2));
when(contentService.listSnapshots(any()))
.thenReturn(Flux.empty());
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);
singlePageReconciler.reconcile(new Reconciler.Request(name));
@ -172,7 +170,8 @@ class SinglePageReconcilerTest {
page.getSpec().setPublish(false);
when(client.fetch(eq(SinglePage.class), eq(name)))
.thenReturn(Optional.of(page));
when(contentService.getContent(eq(page.getSpec().getHeadSnapshot())))
when(singlePageService.getContent(eq(page.getSpec().getHeadSnapshot()),
eq(page.getSpec().getBaseSnapshot())))
.thenReturn(Mono.just(ContentWrapper.builder()
.snapshotName(page.getSpec().getHeadSnapshot())
.raw("hello world")
@ -181,8 +180,8 @@ class SinglePageReconcilerTest {
.build())
);
when(contentService.listSnapshots(any()))
.thenReturn(Flux.empty());
when(client.list(eq(Snapshot.class), any(), any()))
.thenReturn(List.of());
ArgumentCaptor<SinglePage> captor = ArgumentCaptor.forClass(SinglePage.class);
singlePageReconciler.reconcile(new Reconciler.Request(name));

View File

@ -20,8 +20,8 @@ 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.PostService;
import run.halo.app.core.extension.content.Post;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
@ -47,10 +47,10 @@ class PostFinderImplTest {
private ReactiveExtensionClient client;
@Mock
private ContentService contentService;
private CounterService counterService;
@Mock
private CounterService counterService;
private PostService postService;
@Mock
private CategoryFinder categoryFinder;
@ -74,9 +74,7 @@ class PostFinderImplTest {
.content("content")
.rawType("rawType")
.build();
when(client.fetch(eq(Post.class), eq("post-1")))
.thenReturn(Mono.just(post));
when(contentService.getContent(post.getSpec().getReleaseSnapshot()))
when(postService.getReleaseContent(eq(post.getMetadata().getName())))
.thenReturn(Mono.just(contentWrapper));
ContentVo content = postFinder.content("post-1").block();
assertThat(content.getContent()).isEqualTo(contentWrapper.getContent());

View File

@ -14,7 +14,7 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.content.ContentService;
import run.halo.app.content.SinglePageService;
import run.halo.app.core.extension.content.SinglePage;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
@ -34,7 +34,7 @@ class SinglePageFinderImplTest {
private ReactiveExtensionClient client;
@Mock
private ContentService contentService;
private SinglePageService singlePageService;
@Mock
private ContributorFinder contributorFinder;
@ -61,7 +61,7 @@ class SinglePageFinderImplTest {
when(counterService.getByName(anyString())).thenReturn(Mono.empty());
when(contributorFinder.getContributor(anyString())).thenReturn(Mono.empty());
when(contentService.getContent(anyString())).thenReturn(Mono.empty());
when(singlePageService.getReleaseContent(anyString())).thenReturn(Mono.empty());
singlePageFinder.getByName(fakePageName)
.as(StepVerifier::create)
@ -71,9 +71,9 @@ class SinglePageFinderImplTest {
})
.verifyComplete();
verify(client, times(2)).fetch(SinglePage.class, fakePageName);
verify(client, times(1)).fetch(SinglePage.class, fakePageName);
verify(counterService).getByName(anyString());
verify(contentService).getContent(anyString());
verify(singlePageService).getReleaseContent(anyString());
verify(contributorFinder).getContributor(anyString());
}
}