Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow HtmxResponse to be used as return type in error handlers #95

Merged
merged 2 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,27 @@ public HtmxResponse getMainAndPartial(Model model){

Using `ModelAndView` means that each fragment can have its own model (which is merged with the controller model before rendering).

### Error handlers

It is possible to use `HtmxResponse` as a return type from error handlers.
This makes it quite easy to declare a global error handler that will show a message somewhere whenever there is an error
by declaring a global error handler like this:

```java

@ExceptionHandler(Exception.class)
public HtmxResponse handleError(Exception ex) {
return HtmxResponse.builder()
.reswap(HtmxReswap.none())
.view(new ModelAndView("fragments :: error-message", Map.of("message", ex.getMessage())))
.build();
}
```

This will override the normal swapping behaviour of any htmx request that has an exception to avoid swapping to occur.
If the `error-message` fragment is declared as an Out Of Band Swap and your page layout has an empty div to "receive"
that piece of HTML, then only that will be placed on the screen.

### Spring Security

The library has an `HxRefreshHeaderAuthenticationEntryPoint` that you can use to have htmx force a full page browser
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.util.List;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcRegistrations;
import org.springframework.util.Assert;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.List;

@AutoConfiguration
@ConditionalOnWebApplication
Expand All @@ -25,7 +25,9 @@ public class HtmxMvcAutoConfiguration implements WebMvcRegistrations, WebMvcConf
private final ObjectFactory<LocaleResolver> locales;
private final ObjectMapper objectMapper;

HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory<ViewResolver> resolver, ObjectFactory<LocaleResolver> locales, ObjectMapper objectMapper) {
HtmxMvcAutoConfiguration(@Qualifier("viewResolver") ObjectFactory<ViewResolver> resolver,
ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
Assert.notNull(resolver, "ViewResolver must not be null!");
Assert.notNull(locales, "LocaleResolver must not be null!");

Expand All @@ -42,11 +44,15 @@ public RequestMappingHandlerMapping getRequestMappingHandlerMapping() {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new HtmxHandlerInterceptor());
registry.addInterceptor(new HtmxViewHandlerInterceptor(resolver.getObject(), locales, objectMapper));
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new HtmxHandlerMethodArgumentResolver());
}

@Override
public void addReturnValueHandlers(List<HandlerMethodReturnValueHandler> handlers) {
handlers.add(new HtmxResponseHandlerMethodReturnValueHandler(resolver.getObject(), locales, objectMapper));
}
}
Original file line number Diff line number Diff line change
@@ -1,103 +1,77 @@
/*
* Copyright 2021-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;

import jakarta.servlet.http.HttpServletRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectFactory;
import org.springframework.core.MethodParameter;
import org.springframework.util.Assert;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodReturnValueHandler;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.util.ContentCachingResponseWrapper;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
* A {@link HandlerInterceptor} that turns {@link HtmxResponse} instances
* returned from controller methods into a
*
* @author Oliver Drotbohm
* @author Sascha Woo
*/
class HtmxViewHandlerInterceptor implements HandlerInterceptor {

private static final Logger LOGGER = LoggerFactory.getLogger(HtmxViewHandlerInterceptor.class);
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.stream.Collectors;

public class HtmxResponseHandlerMethodReturnValueHandler implements HandlerMethodReturnValueHandler {
private final ViewResolver views;
private final ObjectFactory<LocaleResolver> locales;
private final ObjectMapper objectMapper;

public HtmxViewHandlerInterceptor(ViewResolver views, ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
public HtmxResponseHandlerMethodReturnValueHandler(ViewResolver views,
ObjectFactory<LocaleResolver> locales,
ObjectMapper objectMapper) {
this.views = views;
this.locales = locales;
this.objectMapper = objectMapper;
}

/*
* (non-Javadoc)
*
* @see
* org.springframework.web.servlet.HandlerInterceptor#postHandle(javax.servlet.
* http.HttpServletRequest, javax.servlet.http.HttpServletResponse,
* java.lang.Object, org.springframework.web.servlet.ModelAndView)
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {

if (modelAndView == null || !HandlerMethod.class.isInstance(handler)) {
return;
}

HandlerMethod method = (HandlerMethod) handler;
public boolean supportsReturnType(MethodParameter returnType) {
return returnType.getParameterType().equals(HtmxResponse.class);
}

String partialsAttributeName = null;
if (method.getReturnType().getParameterType().equals(HtmxResponse.class)) {
partialsAttributeName = "htmxResponse";
}
if (partialsAttributeName == null) {
return;
}
@Override
public void handleReturnValue(Object returnValue,
MethodParameter returnType,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest) throws Exception {

Object attribute = modelAndView.getModel().get(partialsAttributeName);
HtmxResponse htmxResponse = (HtmxResponse) returnValue;
mavContainer.setView(toView(htmxResponse));

if (!HtmxResponse.class.isInstance(attribute)) {
return;
}
addHxHeaders(htmxResponse, webRequest.getNativeResponse(HttpServletResponse.class));
}

HtmxResponse htmxResponse = (HtmxResponse) attribute;
private View toView(HtmxResponse htmxResponse) {

modelAndView.setView(toView(htmxResponse));
Assert.notNull(htmxResponse, "HtmxResponse must not be null!");

addHxHeaders(htmxResponse, response);
return (model, request, response) -> {
Locale locale = locales.getObject().resolveLocale(request);
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
for (ModelAndView modelAndView : htmxResponse.getViews()) {
View view = modelAndView.getView();
if (view == null) {
view = views.resolveViewName(modelAndView.getViewName(), locale);
}
for (String key : model.keySet()) {
if (!modelAndView.getModel().containsKey(key)) {
modelAndView.getModel().put(key, model.get(key));
}
}
Assert.notNull(view, "Template '" + modelAndView + "' could not be resolved");
view.render(modelAndView.getModel(), request, wrapper);
}
wrapper.copyBodyToResponse();
};
}

private void addHxHeaders(HtmxResponse htmxResponse, HttpServletResponse response) {
Expand Down Expand Up @@ -158,36 +132,11 @@ private void addHxTriggerHeaders(HttpServletResponse response, HtmxResponseHeade
setHeaderJsonValue(response, headerName.getValue(), triggerMap);
}

private View toView(HtmxResponse partials) {

Assert.notNull(partials, "HtmxPartials must not be null!");

return (model, request, response) -> {
Locale locale = locales.getObject().resolveLocale(request);
ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response);
for (ModelAndView template : partials.getTemplates()) {
View view = template.getView();
if (view == null) {
view = views.resolveViewName(template.getViewName(), locale);
}
for (String key: model.keySet()) {
if(!template.getModel().containsKey(key)) {
template.getModel().put(key, model.get(key));
}
}
Assert.notNull(view, "Template '" + template + "' could not be resolved");
view.render(template.getModel(), request, wrapper);
}
wrapper.copyBodyToResponse();
};
}

private void setHeaderJsonValue(HttpServletResponse response, String name, Object value) {
try {
response.setHeader(name, objectMapper.writeValueAsString(value));
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Unable to set header " + name + " to " + value, e);
}
}

}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package io.github.wimdeblauwe.htmx.spring.boot.mvc;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

import java.time.Duration;
import java.util.Map;
import java.util.TreeMap;

@Controller
@RequestMapping("/hvhi")
public class HtmxViewHandlerInterceptorController {
public class HtmxResponseHandlerMethodReturnValueHandlerController {

@GetMapping("/hx-location-with-context-data")
public HtmxResponse hxLocationWithContextData() {
Expand Down Expand Up @@ -130,4 +132,16 @@ public HtmxResponse preventHistoryUpdate() {
return HtmxResponse.builder().preventHistoryUpdate().build();
}

@GetMapping("/exception")
public void throwException() {
throw new RuntimeException("Fake exception");
}

@ExceptionHandler(Exception.class)
public HtmxResponse handleError(Exception ex) {
return HtmxResponse.builder()
.reswap(HtmxReswap.none())
.view(new ModelAndView("fragments :: error-message", Map.of("message", ex.getMessage())))
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(HtmxViewHandlerInterceptorController.class)
@WebMvcTest(HtmxResponseHandlerMethodReturnValueHandlerController.class)
@WithMockUser
public class HtmxViewHandlerInterceptorTest {
public class HtmxResponseHandlerMethodReturnValueHandlerTest {

@Autowired
private MockMvc mockMvc;
Expand Down Expand Up @@ -131,4 +132,15 @@ public void testPreventHistoryUpdate() throws Exception {
.andExpect(header().doesNotExist("HX-Replace-Url"));
}

@Test
public void testException() throws Exception {
String html = mockMvc.perform(get("/hvhi/exception"))
.andExpect(status().isOk())
.andExpect(header().string("HX-Reswap", "none"))
.andReturn().getResponse().getContentAsString();
assertThat(html).contains("""
<span hx-swap-oob="true">
<span>Fake exception</span>
</span>""");
}
}
4 changes: 4 additions & 0 deletions htmx-spring-boot/src/test/resources/templates/fragments.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@
<span th:text="*{name}">Item name</span>
</span>

<span th:fragment="error-message" hx-swap-oob="true">
<span th:text="${message}"></span>
</span>

</html>