Add event handling for attachment changes and update thumbnail service integration

feat/add-thumbnail-router
John Niang 2025-09-29 15:54:23 +08:00
parent 68e80d16ed
commit 230550d0df
No known key found for this signature in database
GPG Key ID: D7363C015BBCAA59
7 changed files with 163 additions and 411 deletions

View File

@ -0,0 +1,22 @@
package run.halo.app.core.attachment;
import lombok.Getter;
import org.springframework.context.ApplicationEvent;
import run.halo.app.core.extension.attachment.Attachment;
/**
* Event triggered when an attachment is created, updated, or deleted.
*
* @author johnniang
*/
public class AttachmentChangedEvent extends ApplicationEvent {
@Getter
private final Attachment attachment;
public AttachmentChangedEvent(Object source, Attachment attachment) {
super(source);
this.attachment = attachment;
}
}

View File

@ -1,33 +1,32 @@
package run.halo.app.core.attachment; package run.halo.app.core.attachment;
import java.net.URI; import java.net.URI;
import java.util.Map;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.infra.ExternalLinkProcessor;
/**
* Service for managing thumbnails.
*
* @author johnniang
* @since 2.22.0
*/
public interface ThumbnailService { public interface ThumbnailService {
/** /**
* Generate thumbnail by the given image uri and size. * Get the thumbnail link for the given image URI and size.
* <p>if the imageUri is not absolute, it will be processed by {@link ExternalLinkProcessor}
* .</p>
* <p>if externalUrl is not configured, it will return empty.</p>
* *
* @param imageUri image uri to generate thumbnail * @param permalink the permalink of the image
* @param size thumbnail size to generate * @param size the size of the thumbnail
* @return generated thumbnail uri if success, otherwise empty. * @return the thumbnail link
*/ */
Mono<URI> generate(URI imageUri, ThumbnailSize size); Mono<URI> get(URI permalink, ThumbnailSize size);
/** /**
* <p>Get thumbnail by the given image uri and size.</p> * Get all thumbnail links for the given image URI.
* <p>It depends on the {@link #generate(URI, ThumbnailSize)} method, currently the thumbnail
* generation is limited to the attachment service, that is, the thumbnail is strongly
* associated with the attachment.</p>
* *
* @return if thumbnail exists, return the thumbnail uri, otherwise return the original image * @param permalink the permalink of the image
* uri * @return the map of thumbnail size to thumbnail link
*/ */
Mono<URI> get(URI imageUri, ThumbnailSize size); Mono<Map<ThumbnailSize, URI>> get(URI permalink);
Mono<Void> delete(URI imageUri);
} }

View File

@ -1,162 +1,100 @@
package run.halo.app.core.attachment.impl; package run.halo.app.core.attachment.impl;
import static run.halo.app.extension.index.query.QueryFactory.equal; import com.github.benmanes.caffeine.cache.Cache;
import static run.halo.app.extension.index.query.QueryFactory.startsWith; import com.github.benmanes.caffeine.cache.Caffeine;
import java.net.MalformedURLException;
import java.net.URI; import java.net.URI;
import java.net.URL; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Sort; import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.Assert; import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.AttachmentUtils; import run.halo.app.core.attachment.AttachmentChangedEvent;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailProvider;
import run.halo.app.core.attachment.ThumbnailProvider.ThumbnailContext;
import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSigner;
import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.attachment.extension.Thumbnail; import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.ListOptions; import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.PageRequestImpl;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor; import run.halo.app.extension.index.query.QueryFactory;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor class ThumbnailServiceImpl implements ThumbnailService {
public class ThumbnailServiceImpl implements ThumbnailService {
private final ExtensionGetter extensionGetter; private final Cache<String, Map<ThumbnailSize, URI>> thumbnailCache;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
private final ExternalLinkProcessor externalLinkProcessor;
private final ThumbnailProvider thumbnailProvider;
private final LocalThumbnailService localThumbnailService;
private final Map<CacheKey, Mono<URI>> ongoingTasks = new ConcurrentHashMap<>();
@Override public ThumbnailServiceImpl(ReactiveExtensionClient client) {
public Mono<URI> generate(URI imageUri, ThumbnailSize size) { this.client = client;
var cacheKey = new CacheKey(imageUri, size); this.thumbnailCache = Caffeine.newBuilder()
// Combine caching to implement more elegant deduplication logic, ensure that only .maximumSize(10_000)
// one thread executes the logic of create at the same time, and there is no global lock
// restriction
return ongoingTasks.computeIfAbsent(cacheKey, k -> doGenerate(imageUri, size)
// In the case of concurrency, doGenerate must return the same instance
.doFinally(signalType -> ongoingTasks.remove(cacheKey))
.cache()
);
}
record CacheKey(URI imageUri, ThumbnailSize size) {
}
private Mono<URI> doGenerate(URI imageUri, ThumbnailSize size) {
var imageUrlOpt = toImageUrl(imageUri);
if (imageUrlOpt.isEmpty()) {
return Mono.empty();
}
var imageUrl = imageUrlOpt.get();
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.switchIfEmpty(Mono.defer(() -> create(imageUrl, size)))
.onErrorResume(Throwable.class, e -> {
log.warn("Failed to generate thumbnail for image: {}", imageUrl, e);
return Mono.just(URI.create(imageUrl.toString()));
});
}
@Override
public Mono<URI> get(URI imageUri, ThumbnailSize size) {
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.defaultIfEmpty(imageUri);
}
@Override
public Mono<Void> delete(URI imageUri) {
Assert.notNull(imageUri, "Image uri must not be null");
Mono<Void> deleteMono;
if (imageUri.isAbsolute()) {
deleteMono = thumbnailProvider.delete(AttachmentUtils.toUrl(imageUri));
} else {
// Local thumbnails maybe a relative path, so we need to process it.
deleteMono = localThumbnailService.delete(imageUri);
}
return deleteMono.then(deleteThumbnailRecord(imageUri));
}
private Mono<Void> deleteThumbnailRecord(URI imageUri) {
var imageHash = signatureFor(imageUri);
var listOptions = ListOptions.builder()
.fieldQuery(startsWith(Thumbnail.ID_INDEX, Thumbnail.idIndexFunc(imageHash, "")))
.build(); .build();
return client.listAll(Thumbnail.class, listOptions, Sort.unsorted())
.flatMap(client::delete)
.then();
} }
Optional<URL> toImageUrl(URI imageUri) { @EventListener
try { void handleAttachmentChangedEvent(AttachmentChangedEvent event) {
if (imageUri.isAbsolute()) { invalidateOrUpdateCache(event.getAttachment());
return Optional.of(imageUri.toURL()); }
void invalidateOrUpdateCache(Attachment attachment) {
if (attachment.getStatus() == null) {
return;
}
var permalink = attachment.getStatus().getPermalink();
if (!StringUtils.hasText(permalink)) {
return;
}
if (ExtensionUtil.isDeleted(attachment)) {
thumbnailCache.invalidate(permalink);
return;
}
var thumbnails = attachment.getStatus().getThumbnails();
if (CollectionUtils.isEmpty(thumbnails)) {
thumbnailCache.put(permalink, Map.of());
return;
}
Map<ThumbnailSize, URI> validThumbnails = new HashMap<>();
thumbnails.forEach((key, value) -> {
var size = ThumbnailSize.optionalValueOf(key);
if (size.isPresent() && StringUtils.hasText(value)) {
validThumbnails.put(size.get(), URI.create(value));
} }
var url = new URL(externalLinkProcessor.processLink(imageUri.toString())); });
return Optional.of(url); if (validThumbnails.isEmpty()) {
} catch (MalformedURLException e) { thumbnailCache.put(permalink, Map.of());
// Ignore } else {
thumbnailCache.put(permalink, validThumbnails);
} }
return Optional.empty();
} }
protected Mono<URI> create(URL imageUrl, ThumbnailSize size) { @Override
var context = ThumbnailContext.builder() public Mono<URI> get(URI permalink, ThumbnailSize size) {
.imageUrl(imageUrl) return get(permalink).mapNotNull(thumbnails -> thumbnails.get(size));
.size(size) }
@Override
public Mono<Map<ThumbnailSize, URI>> get(URI permalink) {
var permalinkString = permalink.toASCIIString();
var thumbnails = thumbnailCache.getIfPresent(permalinkString);
if (thumbnails != null) {
return Mono.just(thumbnails);
}
// query from attachments
var listOptions = ListOptions.builder()
.andQuery(QueryFactory.equal("status.permalink", permalinkString))
.build(); .build();
var imageUri = return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort())
localThumbnailService.ensureInSiteUriIsRelative(URI.create(imageUrl.toString()));
return extensionGetter.getEnabledExtensions(ThumbnailProvider.class)
.filterWhen(provider -> provider.supports(context))
.next() .next()
.flatMap(provider -> provider.generate(context)) .map(attachment -> {
.flatMap(uri -> { // Here we allow concurrent updates
var thumb = new Thumbnail(); invalidateOrUpdateCache(attachment);
thumb.setMetadata(new Metadata()); return this.thumbnailCache.getIfPresent(permalinkString);
thumb.getMetadata().setGenerateName("thumb-");
thumb.setSpec(new Thumbnail.Spec()
.setSize(size)
.setThumbnailUri(uri.toASCIIString())
.setImageUri(imageUri.toASCIIString())
.setImageSignature(signatureFor(imageUri))
);
// double check
return fetchThumbnail(imageUri, size)
.map(thumbnail -> URI.create(thumbnail.getSpec().getThumbnailUri()))
.switchIfEmpty(Mono.defer(() -> client.create(thumb)
.thenReturn(uri))
);
}); });
} }
private String signatureFor(URI imageUri) {
var uri = localThumbnailService.ensureInSiteUriIsRelative(imageUri);
return ThumbnailSigner.generateSignature(uri);
}
Mono<Thumbnail> fetchThumbnail(URI imageUri, ThumbnailSize size) {
var imageHash = signatureFor(imageUri);
var id = Thumbnail.idIndexFunc(imageHash, size.name());
return client.listBy(Thumbnail.class, ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
.build(), PageRequestImpl.ofSize(1))
.flatMap(result -> Mono.justOrEmpty(ListResult.first(result)));
}
} }

View File

@ -5,13 +5,13 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import java.net.URI; import java.net.URI;
import java.time.Duration; import java.time.Duration;
import java.util.Optional;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import run.halo.app.core.attachment.ThumbnailService; import run.halo.app.core.attachment.AttachmentChangedEvent;
import run.halo.app.core.extension.attachment.Attachment; import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus; import run.halo.app.core.extension.attachment.Attachment.AttachmentStatus;
import run.halo.app.core.extension.attachment.Constant; import run.halo.app.core.extension.attachment.Constant;
@ -33,7 +33,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
private final AttachmentService attachmentService; private final AttachmentService attachmentService;
private final ThumbnailService thumbnailService; private final ApplicationEventPublisher eventPublisher;
@Override @Override
public Result reconcile(Request request) { public Result reconcile(Request request) {
@ -43,6 +43,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
Set.of(Constant.FINALIZER_NAME))) { Set.of(Constant.FINALIZER_NAME))) {
cleanUpResources(attachment); cleanUpResources(attachment);
client.update(attachment); client.update(attachment);
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
} }
return; return;
} }
@ -74,6 +75,7 @@ public class AttachmentReconciler implements Reconciler<Request> {
attachment.getStatus().setThumbnails(thumbnails); attachment.getStatus().setThumbnails(thumbnails);
log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name()); log.debug("Set attachment thumbnails: {} for {}", thumbnails, request.name());
client.update(attachment); client.update(attachment);
this.eventPublisher.publishEvent(new AttachmentChangedEvent(this, attachment));
}); });
return null; return null;
} }
@ -86,12 +88,6 @@ public class AttachmentReconciler implements Reconciler<Request> {
} }
void cleanUpResources(Attachment attachment) { void cleanUpResources(Attachment attachment) {
var timeout = Duration.ofSeconds(20); attachmentService.delete(attachment).block(Duration.ofSeconds(20));
Optional.ofNullable(attachment.getStatus())
.map(AttachmentStatus::getPermalink)
.map(URI::create)
.ifPresent(uri -> thumbnailService.delete(uri).block(timeout));
attachmentService.delete(attachment).block(timeout);
} }
} }

View File

@ -20,14 +20,11 @@ import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse; import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize; import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.extension.attachment.Attachment;
import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupVersion; import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.index.query.QueryFactory;
/** /**
* Thumbnail endpoint for thumbnail resource access. * Thumbnail endpoint for thumbnail resource access.
@ -39,6 +36,8 @@ import run.halo.app.extension.index.query.QueryFactory;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ThumbnailEndpoint implements CustomEndpoint { public class ThumbnailEndpoint implements CustomEndpoint {
private final ThumbnailService thumbnailService;
private final ReactiveExtensionClient client; private final ReactiveExtensionClient client;
@Override @Override
@ -49,32 +48,49 @@ public class ThumbnailEndpoint implements CustomEndpoint {
builder.operationId("GetThumbnailByUri") builder.operationId("GetThumbnailByUri")
.description("Get thumbnail by URI") .description("Get thumbnail by URI")
.tag(tag) .tag(tag)
.response(responseBuilder() .response(responseBuilder().implementation(Resource.class))
.implementation(Resource.class)); .parameter(parameterBuilder()
ThumbnailQuery.buildParameters(builder); .in(ParameterIn.QUERY)
.name("uri")
.description("The URI of the image")
.required(true)
)
.parameter(parameterBuilder()
.in(ParameterIn.QUERY)
.name("size")
.implementation(ThumbnailSize.class)
.description("The size of the thumbnail")
.required(true)
);
}) })
.build(); .build();
} }
public ThumbnailService getThumbnailService() {
return thumbnailService;
}
private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) { private Mono<ServerResponse> getThumbnailByUri(ServerRequest request) {
var query = new ThumbnailQuery(request.queryParams()); var uri = request.queryParam("uri")
var size = query.getSize(); .filter(StringUtils::isNotBlank)
var uri = query.getUri().toASCIIString(); .map(URI::create);
var listOptions = ListOptions.builder() if (uri.isEmpty()) {
.andQuery(ExtensionUtil.notDeleting()) return Mono.error(
.andQuery(QueryFactory.equal("status.permalink", uri)) new ServerWebInputException("Required parameter 'uri' is missing or invalid")
.build(); );
// query by permalink }
return client.listAll(Attachment.class, listOptions, ExtensionUtil.defaultSort()) var size = request.queryParam("size")
// find the first one .filter(StringUtils::isNotBlank)
.next() .flatMap(ThumbnailSize::optionalValueOf);
.mapNotNull(attachment -> { if (size.isEmpty()) {
var thumbnails = attachment.getStatus().getThumbnails(); return Mono.error(
return thumbnails.get(size.name()); new ServerWebInputException("Required parameter 'size' is missing or invalid")
}) );
.defaultIfEmpty(uri) }
return thumbnailService.get(uri.get(), size.get())
.defaultIfEmpty(uri.get())
.flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND) .flatMap(thumbnailLink -> ServerResponse.status(HttpStatus.FOUND)
.location(URI.create(thumbnailLink)) .location(thumbnailLink)
.build() .build()
); );
} }

View File

@ -1,8 +1,11 @@
package run.halo.app.theme.finders.impl; package run.halo.app.theme.finders.impl;
import java.net.URI;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.attachment.ThumbnailService;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.theme.finders.Finder; import run.halo.app.theme.finders.Finder;
import run.halo.app.theme.finders.ThumbnailFinder; import run.halo.app.theme.finders.ThumbnailFinder;
@ -11,9 +14,12 @@ import run.halo.app.theme.finders.ThumbnailFinder;
@RequiredArgsConstructor @RequiredArgsConstructor
public class ThumbnailFinderImpl implements ThumbnailFinder { public class ThumbnailFinderImpl implements ThumbnailFinder {
private final ThumbnailService thumbnailService;
@Override @Override
public Mono<String> gen(String uriStr, String size) { public Mono<String> gen(String uriStr, String size) {
// TODO Implement me return thumbnailService.get(URI.create(uriStr), ThumbnailSize.fromName(size))
return Mono.just(uriStr); .map(URI::toASCIIString);
} }
} }

View File

@ -1,225 +0,0 @@
package run.halo.app.core.attachment.impl;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.assertArg;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isA;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.index.query.QueryFactory.equal;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Nested;
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 org.skyscreamer.jsonassert.JSONAssert;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.attachment.LocalThumbnailProvider;
import run.halo.app.core.attachment.LocalThumbnailService;
import run.halo.app.core.attachment.ThumbnailProvider;
import run.halo.app.core.attachment.ThumbnailSigner;
import run.halo.app.core.attachment.ThumbnailSize;
import run.halo.app.core.attachment.extension.Thumbnail;
import run.halo.app.extension.ListOptions;
import run.halo.app.extension.PageRequest;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ExternalLinkProcessor;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.extensionpoint.ExtensionGetter;
/**
* Tests for {@link ThumbnailServiceImpl}.
*
* @author guqing
* @since 2.19.0
*/
@ExtendWith(MockitoExtension.class)
class ThumbnailServiceImplTest {
@Mock
private ExternalLinkProcessor externalLinkProcessor;
@Mock
private ExtensionGetter extensionGetter;
@Mock
private LocalThumbnailProvider localThumbnailProvider;
@Mock
private LocalThumbnailService localThumbnailService;
@Mock
private ReactiveExtensionClient client;
@InjectMocks
private ThumbnailServiceImpl thumbnailService;
@Test
void toImageUrl() {
var link = "/test.jpg";
when(externalLinkProcessor.processLink(link)).thenReturn("http://localhost:8090/test.jpg");
var imageUrl = thumbnailService.toImageUrl(URI.create(link));
assertThat(imageUrl).isPresent();
assertThat(imageUrl.get().toString()).isEqualTo("http://localhost:8090/test.jpg");
var absoluteLink = "https://halo.run/test.jpg";
imageUrl = thumbnailService.toImageUrl(URI.create(absoluteLink));
assertThat(imageUrl).isPresent();
assertThat(imageUrl.get().toString()).isEqualTo(absoluteLink);
}
@Test
void generateTest() {
var uri = URI.create("http://localhost:8090/test.jpg");
var size = ThumbnailSize.L;
when(localThumbnailService.ensureInSiteUriIsRelative(eq(uri)))
.thenReturn(uri);
var imageHash = ThumbnailSigner.generateSignature(uri.toString());
var id = Thumbnail.idIndexFunc(imageHash, size.name());
var listOptions = ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX, id))
.build();
when(client.listBy(eq(Thumbnail.class), any(), any())).thenReturn(Mono.empty());
var spyThumbnailService = spy(thumbnailService);
doReturn(Mono.empty()).when(spyThumbnailService).create(any(), any());
spyThumbnailService.generate(uri, size)
.as(StepVerifier::create)
.verifyComplete();
verify(client).listBy(eq(Thumbnail.class), assertArg(options -> {
assertThat(options.toString()).isEqualTo(listOptions.toString());
}), isA(PageRequest.class));
}
@Test
void createTest() throws MalformedURLException, URISyntaxException {
var url = new URL("http://localhost:8090/test.jpg");
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
.thenReturn(Flux.just(localThumbnailProvider));
var thumbUri = URI.create("/test-thumb.jpg");
when(localThumbnailProvider.generate(any())).thenReturn(Mono.just(thumbUri));
when(localThumbnailProvider.supports(any())).thenReturn(Mono.just(true));
var insiteUri = URI.create("/test.jpg");
when(localThumbnailService.ensureInSiteUriIsRelative(any()))
.thenReturn(insiteUri);
when(client.create(any())).thenReturn(Mono.empty());
when(client.listBy(eq(Thumbnail.class), any(), isA(PageRequest.class)))
.thenReturn(Mono.empty());
thumbnailService.create(url, ThumbnailSize.M)
.as(StepVerifier::create)
.expectNext(thumbUri)
.verifyComplete();
thumbnailService.fetchThumbnail(url.toURI(), ThumbnailSize.M)
.as(StepVerifier::create)
.verifyComplete();
var hash = ThumbnailSigner.generateSignature(insiteUri.toString());
verify(client, times(2)).listBy(eq(Thumbnail.class),
assertArg(options -> {
var exceptOptions = ListOptions.builder()
.fieldQuery(equal(Thumbnail.ID_INDEX,
Thumbnail.idIndexFunc(hash, ThumbnailSize.M.name())
))
.build();
assertThat(options.toString()).isEqualTo(exceptOptions.toString());
}), isA(PageRequest.class));
verify(localThumbnailProvider).generate(any());
verify(client).create(assertArg(thumb -> {
JSONAssert.assertEquals("""
{
"spec": {
"imageSignature": "%s",
"imageUri": "/test.jpg",
"size": "M",
"thumbnailUri": "/test-thumb.jpg"
},
"apiVersion": "storage.halo.run/v1alpha1",
"kind": "Thumbnail",
"metadata": {
"generateName": "thumb-"
}
}
""".formatted(hash), JsonUtils.objectToJson(thumb), true);
}));
}
@Test
void createTest2() throws MalformedURLException {
when(extensionGetter.getEnabledExtensions(eq(ThumbnailProvider.class)))
.thenReturn(Flux.empty());
// no thumbnail provider will do nothing
var url = new URL("http://localhost:8090/test.jpg");
thumbnailService.create(url, ThumbnailSize.M)
.as(StepVerifier::create)
.verifyComplete();
}
@Nested
class ThumbnailGenerateConcurrencyTest {
@Test
public void concurrentThumbnailGeneration() throws InterruptedException {
var spyThumbnailService = spy(thumbnailService);
URI imageUri = URI.create("http://localhost:8090/test.jpg");
doReturn(Mono.empty()).when(spyThumbnailService).fetchThumbnail(eq(imageUri), any());
var createdUri = URI.create("/test-thumb.jpg");
doReturn(Mono.just(createdUri)).when(spyThumbnailService).create(any(), any());
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
var latch = new CountDownLatch(threadCount);
var results = new ConcurrentLinkedQueue<Mono<URI>>();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
results.add(spyThumbnailService.generate(imageUri, ThumbnailSize.M));
} finally {
latch.countDown();
}
});
}
latch.await();
results.forEach(result -> {
StepVerifier.create(result)
.expectNext(createdUri)
.verifyComplete();
});
verify(spyThumbnailService).fetchThumbnail(eq(imageUri), eq(ThumbnailSize.M));
verify(spyThumbnailService).create(any(), eq(ThumbnailSize.M));
executor.shutdown();
}
}
}