Respond not found if no theme template found (#6511)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.19.0

#### What this PR does / why we need it:

This PR refactors "Template Not Found Exception" into "NotFoundException" to prevent too many exception stacktraces in logs file.

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

Fixes #6501 

#### Special notes for your reviewer:

1. Activate default theme
2. Request <http://localhost:8090/categories>
3. See the result

#### Does this PR introduce a user-facing change?

```release-note
优化当主题模板找不到的异常提示
```
pull/6515/head
John Niang 2024-08-26 10:47:12 +08:00 committed by GitHub
parent 3767a5a239
commit 50adc29e42
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 261 additions and 1 deletions

View File

@ -103,6 +103,7 @@ tasks.named('build') {
tasks.named('test', Test) {
useJUnitPlatform()
maxHeapSize = '1G'
finalizedBy jacocoTestReport
}

View File

@ -0,0 +1,62 @@
package run.halo.app.theme;
import static java.util.Comparator.comparing;
import static java.util.Comparator.naturalOrder;
import static java.util.Comparator.nullsLast;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.templateresolver.ITemplateResolver;
import org.thymeleaf.templateresolver.TemplateResolution;
import run.halo.app.infra.exception.NotFoundException;
/**
* Composite template resolver to control execution flow of multiple template resolvers.
*
* @author johnniang
*/
class CompositeTemplateResolver implements ITemplateResolver {
private final List<ITemplateResolver> resolvers;
public CompositeTemplateResolver(Collection<ITemplateResolver> resolvers) {
this.resolvers = Optional.ofNullable(resolvers).orElseGet(List::of)
.stream()
.distinct()
// we keep the same order comparison as
// org.thymeleaf.EngineConfiguration.TemplateResolverComparator
.sorted(comparing(ITemplateResolver::getOrder, nullsLast(naturalOrder())))
.toList();
}
@Override
public String getName() {
return resolvers.stream()
.map(ITemplateResolver::getName)
.collect(Collectors.joining(", "));
}
@Override
public Integer getOrder() {
// null order means to be the end of the resolvers.
return null;
}
@Override
public TemplateResolution resolveTemplate(IEngineConfiguration configuration,
String ownerTemplate, String template, Map<String, Object> templateResolutionAttributes) {
return resolvers.stream()
.map(resolver -> resolver.resolveTemplate(
configuration, ownerTemplate, template, templateResolutionAttributes)
)
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new NotFoundException("Template " + template + " was not found."));
}
}

View File

@ -4,13 +4,18 @@ import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.attoparser.ParseException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.ErrorResponse;
import org.springframework.web.reactive.result.view.View;
import org.springframework.web.server.ServerWebExchange;
import org.thymeleaf.exceptions.TemplateInputException;
import org.thymeleaf.exceptions.TemplateProcessingException;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveView;
import org.thymeleaf.spring6.view.reactive.ThymeleafReactiveViewResolver;
import reactor.core.publisher.Flux;
@ -54,7 +59,26 @@ public class HaloViewResolver extends ThymeleafReactiveViewResolver {
// calculate the engine before rendering
setTemplateEngine(engineManager.getTemplateEngine(theme));
exchange.getAttributes().put(ModelConst.POWERED_BY_HALO_TEMPLATE_ENGINE, true);
return super.render(model, contentType, exchange);
return super.render(model, contentType, exchange)
.onErrorMap(TemplateProcessingException.class::isInstance, tee -> {
if (tee instanceof TemplateInputException) {
// map the error response exception while fragment not found
return Optional.of(tee)
.map(Throwable::getCause)
.filter(ParseException.class::isInstance)
.map(Throwable::getCause)
.filter(TemplateProcessingException.class::isInstance)
.map(Throwable::getCause)
.filter(ErrorResponse.class::isInstance)
.orElse(tee);
}
// map the error response exception while template not found
return Optional.of(tee)
.map(Throwable::getCause)
.filter(ErrorResponse.class::isInstance)
.orElse(tee);
});
});
}

View File

@ -137,6 +137,13 @@ public class TemplateEngineManager {
engine.addDialect(new HaloProcessorDialect());
templateResolvers.orderedStream().forEach(engine::addTemplateResolver);
// we collect all template resolvers and add them into composite template resolver
// to control the resolution flow
var compositeTemplateResolver =
new CompositeTemplateResolver(engine.getTemplateResolvers());
engine.setTemplateResolver(compositeTemplateResolver);
dialects.orderedStream().forEach(engine::addDialect);
return engine;

View File

@ -0,0 +1,73 @@
package run.halo.app.theme;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.thymeleaf.templateresolver.ITemplateResolver;
import org.thymeleaf.templateresolver.TemplateResolution;
import run.halo.app.infra.exception.NotFoundException;
class CompositeTemplateResolverTest {
@Test
void shouldGetBlankNameIfNoResolvers() {
var resolver = new CompositeTemplateResolver(null);
assertEquals("", resolver.getName());
}
@Test
void shouldGetNameIfResolversProvided() {
var resolverA = mock(ITemplateResolver.class);
when(resolverA.getName()).thenReturn("A");
var resolverB = mock(ITemplateResolver.class);
when(resolverB.getName()).thenReturn("B");
var resolver = new CompositeTemplateResolver(List.of(resolverB, resolverA));
assertEquals("B, A", resolver.getName());
}
@Test
void shouldGetNullOrder() {
var resolver = new CompositeTemplateResolver(null);
assertNull(resolver.getOrder());
}
@Test
void shouldThrowNotFoundExceptionIfNoResolvers() {
var resolver = new CompositeTemplateResolver(null);
assertThrows(
NotFoundException.class,
() -> resolver.resolveTemplate(null, null, null, null)
);
}
@Test
void shouldThrowNotFoundExceptionIfAllResolversReturnNull() {
var resolverA = mock(ITemplateResolver.class);
when(resolverA.resolveTemplate(null, null, null, null)).thenReturn(null);
var resolverB = mock(ITemplateResolver.class);
when(resolverB.resolveTemplate(null, null, null, null)).thenReturn(null);
var resolver = new CompositeTemplateResolver(List.of(resolverA, resolverB));
assertThrows(
NotFoundException.class,
() -> resolver.resolveTemplate(null, null, null, null)
);
}
@Test
void shouldResolveTemplateIfResolvedByOneOfResolvers() {
var resolverA = mock(ITemplateResolver.class);
var resolution = mock(TemplateResolution.class);
when(resolverA.resolveTemplate(null, null, null, null))
.thenReturn(resolution);
var resolverB = mock(ITemplateResolver.class);
when(resolverB.resolveTemplate(null, null, null, null))
.thenReturn(null);
var resolver = new CompositeTemplateResolver(List.of(resolverA, resolverB));
assertEquals(resolution, resolver.resolveTemplate(null, null, null, null));
}
}

View File

@ -0,0 +1,93 @@
package run.halo.app.theme;
import static org.mockito.Mockito.when;
import java.util.LinkedHashSet;
import java.util.List;
import org.hamcrest.Matchers;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerResponse;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.infra.InitializationStateGetter;
@SpringBootTest
@Import(ThemeIntegrationTest.TestConfig.class)
@AutoConfigureWebTestClient
@DirtiesContext
public class ThemeIntegrationTest {
@Autowired
WebTestClient webClient;
@MockBean
InitializationStateGetter initializationStateGetter;
@Autowired
ExtensionClient client;
@BeforeEach
void setUp() {
when(initializationStateGetter.userInitialized()).thenReturn(Mono.just(true));
// create a menu item
var menuItem = new MenuItem();
menuItem.setMetadata(new Metadata());
menuItem.getMetadata().setName("main-menu-home");
menuItem.setSpec(new MenuItem.MenuItemSpec());
menuItem.getSpec().setDisplayName("Home");
menuItem.getSpec().setHref("/");
client.create(menuItem);
// create a primary menu
var menu = new Menu();
menu.setMetadata(new Metadata());
menu.getMetadata().setName("main-menu");
menu.setSpec(new Menu.Spec());
menu.getSpec().setDisplayName("Mail Menu");
menu.getSpec().setMenuItems(new LinkedHashSet<>(List.of("main-menu-home")));
client.create(menu);
}
@TestConfiguration
static class TestConfig {
@Bean
RouterFunction<ServerResponse> noTemplateExistsRoute() {
return RouterFunctions.route()
.GET(
"/no-template-exists",
request -> ServerResponse.ok().render("no-template-exists")
)
.build();
}
}
@Test
void shouldRespondNotFoundIfNoTemplateFound() {
webClient.get().uri("/no-template-exists")
.accept(MediaType.TEXT_HTML)
.exchange()
.expectStatus().isNotFound()
.expectBody(String.class)
.value(Matchers.containsString("Template no-template-exists was not found"));
}
}