View Javadoc
1   package de.juplo.httpresources;
2   
3   import org.apache.http.entity.StringEntity;
4   import org.junit.jupiter.api.BeforeEach;
5   import org.junit.jupiter.api.Test;
6   import org.mockserver.integration.ClientAndServer;
7   import org.slf4j.Logger;
8   import org.slf4j.LoggerFactory;
9   import org.springframework.beans.factory.annotation.Autowired;
10  import org.springframework.boot.autoconfigure.SpringBootApplication;
11  import org.springframework.boot.autoconfigure.thymeleaf.ThymeleafProperties;
12  import org.springframework.boot.test.context.SpringBootTest;
13  import org.springframework.cache.Cache;
14  import org.springframework.context.ApplicationContext;
15  import org.springframework.context.annotation.Bean;
16  import org.springframework.core.io.Resource;
17  import org.springframework.stereotype.Controller;
18  import org.springframework.test.web.servlet.MockMvc;
19  import org.springframework.test.web.servlet.setup.MockMvcBuilders;
20  import org.springframework.ui.Model;
21  import org.springframework.web.bind.annotation.RequestMapping;
22  import org.springframework.web.bind.annotation.RequestParam;
23  import org.springframework.web.context.WebApplicationContext;
24  import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
25  import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
26  import org.springframework.web.util.NestedServletException;
27  import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
28  
29  import java.io.IOException;
30  import java.net.URI;
31  import java.net.URISyntaxException;
32  import java.nio.file.Files;
33  import java.nio.file.Paths;
34  import java.time.Duration;
35  import java.util.Scanner;
36  import java.util.stream.Collectors;
37  
38  import static org.junit.jupiter.api.Assertions.assertThrows;
39  import static org.mockserver.model.HttpRequest.request;
40  import static org.mockserver.verify.VerificationTimes.exactly;
41  import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
42  import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
43  import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
44  import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
45  
46  
47  /**
48   * This test-case showcases the usage of the {@link HttpResourceProtocolResolver}
49   * with Thymeleaf.
50   *
51   * Since Thymeleaf does not use the Resource-Chain mechanism, that Spring introduced
52   * for the handling of static resources, the {@code HttpResourceProtocolResolver} has
53   * to be used and the protocol has to be hardwired into the configuration as
54   * {@code spring.thymeleaf.prefix}.
55   * The downside of this approach is, that the templates are only resolved against
56   * the configured remote-URI and do not take part in the fallback-mechanism, that
57   * can be configured through {@code spring.resources.static-locations}.
58   *
59   * The test-case was addapted accordingly, to show the changed behaviour.
60   */
61  @SpringBootTest({
62      "juplo.http-resources.protocol-resolver.enabled=true",
63      "spring.thymeleaf.cache=true" })
64  public class ThymeleafWithHttpResourceProtocolResolverIT extends IntegrationTestBase
65  {
66    private static final Logger LOG =
67        LoggerFactory.getLogger(ThymeleafWithHttpResourceProtocolResolverIT.class);
68  
69  
70    @Autowired
71    HttpResources resources;
72    @Autowired
73    Cache cache;
74    @Autowired
75    WebApplicationContext context;
76  
77    MockMvc mvc;
78  
79  
80    @BeforeEach
81    public void setUp()
82    {
83      cache.clear();
84      mvc = MockMvcBuilders
85          .webAppContextSetup(context)
86          .alwaysDo(print())
87          .build();
88    }
89  
90  
91    @Test
92    public void testRenderLocalTemplate() throws Exception
93    {
94      LOG.info("<-- start of test-case");
95  
96      assertThrows(
97          NestedServletException.class,
98          () -> mvc.perform(get(URI.create("http://test/controller.html?template=local"))));
99  
100     server.verify(FETCH("/local.html"), exactly(2));
101   }
102 
103   @Test
104   public void testRenderRemoteTemplate() throws Exception
105   {
106     LOG.info("<-- start of test-case");
107 
108     mvc
109         .perform(get(URI.create("http://test/controller.html?template=remote")))
110         .andExpect(status().isOk())
111         .andExpect(content().string(read("/rendered/remote.html")));
112 
113     server.verify(FETCH("/remote.html"), exactly(1));
114   }
115 
116   @Test
117   public void testRenderCachedRemoteTemplate() throws Exception
118   {
119     LOG.info("<-- start of test-case");
120 
121     mvc
122         .perform(get(URI.create("http://test/controller.html?template=remote")))
123         .andExpect(status().isOk())
124         .andExpect(content().string(read("/rendered/remote.html")));
125 
126     mvc
127         .perform(get(URI.create("http://test/controller.html?template=remote")))
128         .andExpect(status().isOk())
129         .andExpect(content().string(read("/rendered/remote.html")));
130 
131     mvc
132         .perform(get(URI.create("http://test/controller.html?template=remote")))
133         .andExpect(status().isOk())
134         .andExpect(content().string(read("/rendered/remote.html")));
135 
136     mvc
137         .perform(get(URI.create("http://test/controller.html?template=remote")))
138         .andExpect(status().isOk())
139         .andExpect(content().string(read("/rendered/remote.html")));
140 
141     server.verify(FETCH("/remote.html"), exactly(1));
142   }
143 
144   @Test
145   public void testRenderModifiedRemoteTemplate() throws Exception
146   {
147     LOG.info("<-- start of test-case");
148 
149     mvc
150         .perform(get(URI.create("http://test/controller.html?template=remote")))
151         .andExpect(status().isOk())
152         .andExpect(content().string(read("/rendered/remote.html")));
153 
154     CLOCK.timetravel(Duration.ofSeconds(10));
155     server.when(FETCH("/remote.html")).forward(NGINX("/modified.html"));
156 
157     mvc
158         .perform(get(URI.create("http://test/controller.html?template=remote")))
159         .andExpect(status().isOk())
160         .andExpect(content().string(read("/rendered/modified.html")));
161 
162     server.verify(FETCH("/remote.html"), exactly(2));
163     server.verify(
164         request()
165             .withPath("/remote.html")
166             .withHeader("If-Modified-Since")
167             .withHeader("If-None-Match"),
168         exactly(1));
169   }
170 
171 
172   @Controller
173   public static class TestController
174   {
175 
176     @RequestMapping("/controller.html")
177     public String controller(
178         @RequestParam String template,
179         Model model
180         )
181     {
182       model.addAttribute("template", template);
183       return template;
184     }
185 
186   }
187 
188 
189   static StringEntity body(String resource) throws URISyntaxException, IOException
190   {
191     return new StringEntity(read(resource));
192   }
193 
194   static String read(String resource) throws URISyntaxException, IOException
195   {
196     URI uri = ThymeleafWithHttpResourceProtocolResolverIT.class.getResource(resource).toURI();
197     return Files.readAllLines(Paths.get(uri)).stream().collect(Collectors.joining("\n"));
198   }
199 
200   static String read(Resource resource) throws URISyntaxException, IOException
201   {
202     Scanner s = new Scanner(resource.getInputStream()).useDelimiter("\\A");
203     return s.hasNext() ? s.next() : "";
204   }
205 
206 
207   @SpringBootApplication
208   public static class Application implements WebMvcConfigurer
209   {
210     @Autowired
211     ClientAndServer server;
212 
213     /**
214      * This mimics the autoconfiguration of Thymeleaf in Spring-Boot.
215      * Reason: Found no other way to inject the property {@code spring.thymeleaf.prefix} dynamically
216      */
217     @Bean
218     public SpringResourceTemplateResolver defaultTemplateResolver(
219         ThymeleafProperties properties,
220         ApplicationContext applicationContext,
221         ClientAndServer server)
222     {
223       SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
224       resolver.setApplicationContext(applicationContext);
225       resolver.setPrefix("http://localhost:" + server.getLocalPort() + "/"); // << Cannot be passed in via properties
226       resolver.setSuffix(properties.getSuffix());
227       resolver.setTemplateMode(properties.getMode());
228       if (properties.getEncoding() != null)
229       {
230         resolver.setCharacterEncoding(properties.getEncoding().name());
231       }
232       resolver.setCacheable(false); // << With caching enabled, the caching of HttpResources cannot be tested
233       Integer order = properties.getTemplateResolverOrder();
234       if (order != null)
235       {
236         resolver.setOrder(order);
237       }
238       resolver.setCheckExistence(properties.isCheckTemplate());
239       return resolver;
240     }
241 
242     @Bean
243     public TestController testController()
244     {
245       return new TestController();
246     }
247 
248     @Override
249     public void addResourceHandlers(ResourceHandlerRegistry registry)
250     {
251       LOG.info(
252           "{} resource-handler for static location {}",
253           registry.hasMappingForPattern("/**") ? "Overwriting" : "Setting",
254           "/**"
255       );
256       registry
257           .addResourceHandler("/**")
258           .addResourceLocations(
259               "classpath:/static/",
260               "classpath:/public/",
261               "http://localhost:" + server.getLocalPort(),
262               "classpath:/fallback/")
263           .resourceChain(false);
264     }
265   }
266 }