2ea7762db5ed2ccec528d679a04e5f84a4794651
[maven-thymeleaf-skin] / src / main / java / de / juplo / thymeproxy / ProxyHttpRequestHandler.java
1 package de.juplo.thymeproxy;
2
3
4 import java.io.IOException;
5 import java.time.Clock;
6 import java.time.Duration;
7 import java.time.OffsetDateTime;
8 import java.time.format.DateTimeFormatter;
9 import java.time.format.DateTimeParseException;
10 import java.util.concurrent.TimeUnit;
11 import javax.servlet.ServletException;
12 import javax.servlet.http.HttpServletRequest;
13 import javax.servlet.http.HttpServletResponse;
14 import org.apache.http.Header;
15 import org.apache.http.HeaderElement;
16 import org.apache.http.HttpEntity;
17 import org.apache.http.HttpResponse;
18 import org.apache.http.StatusLine;
19 import org.apache.http.client.methods.CloseableHttpResponse;
20 import org.apache.http.client.methods.HttpGet;
21 import org.apache.http.impl.client.CloseableHttpClient;
22 import org.apache.http.util.EntityUtils;
23 import org.slf4j.Logger;
24 import org.slf4j.LoggerFactory;
25 import org.springframework.http.CacheControl;
26 import org.springframework.util.Assert;
27 import org.springframework.util.StreamUtils;
28 import org.springframework.util.StringUtils;
29 import org.springframework.web.HttpRequestHandler;
30 import org.springframework.web.servlet.HandlerMapping;
31 import org.springframework.web.servlet.support.WebContentGenerator;
32 import org.springframework.web.util.UrlPathHelper;
33
34
35
36 /**
37  *
38  * @author kai
39  */
40 public class ProxyHttpRequestHandler
41     extends
42       WebContentGenerator
43     implements
44       HttpRequestHandler
45 {
46   private final static Logger LOG =
47       LoggerFactory.getLogger(ProxyHttpRequestHandler.class);
48
49   
50   public final static Long DEFAULT_TTL = 300000l; /** 5 minutes */
51
52
53   private UrlPathHelper urlPathHelper = new UrlPathHelper();
54
55   private CloseableHttpClient client;
56   private String origin;
57   private Long ttl;
58
59   private final Clock clock;
60
61
62   public ProxyHttpRequestHandler()
63   {
64     clock = Clock.systemDefaultZone();
65   }
66
67   public ProxyHttpRequestHandler(Clock clock)
68   {
69     this.clock = clock;
70   }
71
72
73   @Override
74   public void handleRequest(
75       HttpServletRequest request,
76       HttpServletResponse response
77       )
78       throws
79         ServletException,
80         IOException
81   {
82     LOG.debug("handling: {}", request.getRequestURI());
83
84     String path =
85         (String)
86         request.getAttribute(
87             HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE
88             );
89
90     if (!StringUtils.hasText(path))
91       path = urlPathHelper.getLookupPathForRequest(request);
92
93     StringBuilder builder = new StringBuilder();
94     builder.append(origin);
95     builder.append(path);
96
97     String query = request.getQueryString();
98     if (query != null)
99     {
100       builder.append('?');
101       builder.append(query);
102     }
103
104     String resource = builder.toString();
105
106     CloseableHttpResponse p_response = null;
107     try
108     {
109       HttpGet p_request = new HttpGet(resource);
110
111       p_response = client.execute(p_request);
112
113       StatusLine status = p_response.getStatusLine();
114       int code = status.getStatusCode();
115       LOG.info("{} - {}: {}", code, status.getReasonPhrase(), resource);
116
117       setCacheControl(computeCacheControl(p_response));
118       prepareResponse(response);
119
120       /** Copy some headers, if present.. */
121       ProxyHttpRequestHandler.copyHeader(p_response, response, "Date");
122       ProxyHttpRequestHandler.copyHeader(p_response, response, "ETag");
123       ProxyHttpRequestHandler.copyHeader(p_response, response, "Last-Modified");
124       ProxyHttpRequestHandler.copyHeader(p_response, response, "Content-Length");
125       ProxyHttpRequestHandler.copyHeader(p_response, response, "Content-Type");
126
127       switch (code)
128       {
129         case HttpServletResponse.SC_FOUND:
130         case HttpServletResponse.SC_OK:
131           /** OK. Continue as normal... */
132           response.setStatus(code);
133
134           HttpEntity entity = p_response.getEntity();
135
136           StreamUtils.copy(entity.getContent(), response.getOutputStream());
137
138           /** Release the connection */
139           EntityUtils.consume(entity);
140           p_response.close();
141
142           return;
143
144         case HttpServletResponse.SC_NOT_FOUND:
145         case HttpServletResponse.SC_GONE:
146           /** The resource can not be resolved through this origin */
147           response.sendError(code, status.getReasonPhrase());
148           return;
149
150         case HttpServletResponse.SC_MOVED_PERMANENTLY:
151           // TODO
152
153         case HttpServletResponse.SC_SEE_OTHER:
154           // TODO
155
156         case HttpServletResponse.SC_TEMPORARY_REDIRECT:
157           // TODO
158
159         default:
160           LOG.error("{} -- {}", p_response.getStatusLine(), resource);
161           // TODO: throw sensible exceptions, to communicate resolving-errors
162           throw new RuntimeException(p_response.getStatusLine().toString() + " -- " + resource);
163       }
164     }
165     catch (IOException e)
166     {
167       LOG.error("unexpected error while resolving {}: {}", resource, e.getMessage());
168       // TODO: throw sensible exceptions, to communicate resolving-errors
169       throw new RuntimeException(e);
170     }
171     finally
172     {
173       if (p_response != null)
174       {
175         EntityUtils.consumeQuietly(p_response.getEntity());
176         p_response.close();
177       }
178     }
179   }
180
181   public static void copyHeader(
182       HttpResponse source,
183       HttpServletResponse target,
184       String name
185       )
186   {
187     Header header = source.getLastHeader(name);
188     if (header != null)
189     {
190       LOG.trace("copy header {}: {}", header.getName(), header.getValue());
191       target.addHeader(header.getName(), header.getValue());
192     }
193   }
194
195   public CacheControl computeCacheControl(HttpResponse response)
196   {
197     if (ttl == null)
198       /** Caching is disabled! */
199       return CacheControl.noStore();
200
201
202     boolean has_cache_control = false;
203     boolean is_public = false;
204     boolean is_private = false;
205     boolean no_cache = false;
206     boolean no_store = false;
207     boolean no_transform = false;
208     boolean must_revalidate = false;
209     boolean proxy_revalidate = false;
210     Long max_age = null;
211     Long s_maxage = null;
212
213     for (Header header : response.getHeaders("Cache-Control"))
214     {
215       has_cache_control = true;
216       for (HeaderElement element : header.getElements())
217       {
218         switch (element.getName())
219         {
220           case "public":
221             is_public = true;
222             break;
223           case "private":
224             is_private = true;
225             break;
226           case "no-cache":
227             no_cache = true;
228             break;
229           case "no-store":
230             no_store = true;
231             break;
232           case "no_transform":
233             no_transform = true;
234             break;
235           case "must-revalidate":
236             must_revalidate = true;
237             break;
238           case "proxy-revalidate":
239             proxy_revalidate = true;
240           case "max-age":
241             try
242             {
243               max_age = Long.parseLong(element.getValue());
244             }
245             catch (NumberFormatException e)
246             {
247               LOG.warn(
248                   "invalid header \"Cache-Control: max-age={}\"",
249                   element.getValue()
250                   );
251             }
252             break;
253           case "s-maxage":
254             try
255             {
256               s_maxage = Long.parseLong(element.getValue());
257             }
258             catch (NumberFormatException e)
259             {
260               LOG.warn(
261                   "invalid header \"Cache-Control: s-maxage={}\"",
262                   element.getValue()
263                   );
264             }
265             break;
266           default:
267               LOG.warn(
268                   "invalid header \"Cache-Control: {}{}\"",
269                   element.getName(),
270                   element.getValue() == null ? "" : "=" + element.getValue()
271                   );
272         }
273       }
274     }
275
276     if (!has_cache_control)
277     {
278       Header header = response.getLastHeader("Expires");
279       if (header == null)
280         /** No TTL specified in response-headers: use configured default */
281         max_age = ttl;
282       else
283         try
284         {
285           OffsetDateTime expires
286               = OffsetDateTime.parse(
287                   header.getValue(),
288                   DateTimeFormatter.RFC_1123_DATE_TIME
289               );
290
291           Duration delta = Duration.between(OffsetDateTime.now(clock), expires);
292           if (delta.isNegative())
293             no_store = true;
294           else
295             max_age = delta.getSeconds();
296         }
297         catch (DateTimeParseException e)
298         {
299           LOG.warn("invalid header \"Expires: {}\"", header.getValue());
300           /**
301            * No TTL specified in response-headers: assume expired
302            * see: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21
303            */
304           max_age = 0l;
305         }
306     }
307
308     CacheControl cache_control;
309
310     if (no_store)
311       cache_control = CacheControl.noStore();
312     else if (no_cache)
313       cache_control = CacheControl.noCache();
314     else if (max_age != null)
315       cache_control = CacheControl.maxAge(max_age, TimeUnit.SECONDS);
316     else
317       cache_control = CacheControl.empty();
318
319     if (is_private)
320       cache_control.cachePrivate();
321     if (is_public)
322       cache_control.cachePublic();
323     if (no_transform)
324       cache_control.noTransform();
325     if (must_revalidate)
326       cache_control.mustRevalidate();
327     if (proxy_revalidate)
328       cache_control.proxyRevalidate();
329     if (s_maxage != null)
330       cache_control.sMaxAge(s_maxage, TimeUnit.SECONDS);
331
332     return cache_control;
333   }
334
335
336   public ProxyHttpRequestHandler setClient(CloseableHttpClient client)
337   {
338     this.client = client;
339     return this;
340   }
341
342   public ProxyHttpRequestHandler setOrigin(String origin)
343   {
344     if (origin.endsWith("/"))
345       this.origin = origin;
346     else
347       this.origin = origin + "/";
348     return this;
349   }
350
351   public ProxyHttpRequestHandler setTtl(Long ttl)
352   {
353     this.ttl = ttl;
354     return this;
355   }
356
357   /**
358    * Set if URL lookup should always use full path within current servlet
359    * context. Else, the path within the current servlet mapping is used
360    * if applicable (i.e. in the case of a ".../*" servlet mapping in web.xml).
361    * Default is "false".
362    *
363    * @see org.springframework.web.util.UrlPathHelper#setAlwaysUseFullPath
364    */
365   public ProxyHttpRequestHandler setAlwaysUseFullPath(boolean alwaysUseFullPath)
366   {
367     this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
368     return this;
369   }
370
371   /**
372    * Set if context path and request URI should be URL-decoded.
373    * Both are returned <i>undecoded</i> by the Servlet API,
374    * in contrast to the servlet path.
375    * <p>
376    * Uses either the request encoding or the default encoding according
377    * to the Servlet spec (ISO-8859-1).
378    *
379    * @see org.springframework.web.util.UrlPathHelper#setUrlDecode
380    */
381   public ProxyHttpRequestHandler setUrlDecode(boolean urlDecode)
382   {
383     this.urlPathHelper.setUrlDecode(urlDecode);
384     return this;
385   }
386
387   /**
388    * Set if ";" (semicolon) content should be stripped from the request URI.
389    *
390    * @see
391    * org.springframework.web.util.UrlPathHelper#setRemoveSemicolonContent(boolean)
392    */
393   public ProxyHttpRequestHandler setRemoveSemicolonContent(boolean removeSemicolonContent)
394   {
395     this.urlPathHelper.setRemoveSemicolonContent(removeSemicolonContent);
396     return this;
397   }
398
399   /**
400    * Set the UrlPathHelper to use for the resolution of lookup paths.
401    * <p>
402    * Use this to override the default UrlPathHelper with a custom subclass,
403    * or to share common UrlPathHelper settings across multiple
404    * MethodNameResolvers
405    * and HandlerMappings.
406    *
407    * @see
408    * org.springframework.web.servlet.handler.AbstractUrlHandlerMapping#setUrlPathHelper
409    */
410   public ProxyHttpRequestHandler setUrlPathHelper(UrlPathHelper urlPathHelper)
411   {
412     Assert.notNull(urlPathHelper, "UrlPathHelper must not be null");
413     this.urlPathHelper = urlPathHelper;
414     return this;
415   }
416 }