WIP: proxy
[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     this.origin = origin;
345     return this;
346   }
347
348   public ProxyHttpRequestHandler setTtl(Long ttl)
349   {
350     this.ttl = ttl;
351     return this;
352   }
353
354   /**
355    * Set if URL lookup should always use full path within current servlet
356    * context. Else, the path within the current servlet mapping is used
357    * if applicable (i.e. in the case of a ".../*" servlet mapping in web.xml).
358    * Default is "false".
359    *
360    * @see org.springframework.web.util.UrlPathHelper#setAlwaysUseFullPath
361    */
362   public ProxyHttpRequestHandler setAlwaysUseFullPath(boolean alwaysUseFullPath)
363   {
364     this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
365     return this;
366   }
367
368   /**
369    * Set if context path and request URI should be URL-decoded.
370    * Both are returned <i>undecoded</i> by the Servlet API,
371    * in contrast to the servlet path.
372    * <p>
373    * Uses either the request encoding or the default encoding according
374    * to the Servlet spec (ISO-8859-1).
375    *
376    * @see org.springframework.web.util.UrlPathHelper#setUrlDecode
377    */
378   public ProxyHttpRequestHandler setUrlDecode(boolean urlDecode)
379   {
380     this.urlPathHelper.setUrlDecode(urlDecode);
381     return this;
382   }
383
384   /**
385    * Set if ";" (semicolon) content should be stripped from the request URI.
386    *
387    * @see
388    * org.springframework.web.util.UrlPathHelper#setRemoveSemicolonContent(boolean)
389    */
390   public ProxyHttpRequestHandler setRemoveSemicolonContent(boolean removeSemicolonContent)
391   {
392     this.urlPathHelper.setRemoveSemicolonContent(removeSemicolonContent);
393     return this;
394   }
395
396   /**
397    * Set the UrlPathHelper to use for the resolution of lookup paths.
398    * <p>
399    * Use this to override the default UrlPathHelper with a custom subclass,
400    * or to share common UrlPathHelper settings across multiple
401    * MethodNameResolvers
402    * and HandlerMappings.
403    *
404    * @see
405    * org.springframework.web.servlet.handler.AbstractUrlHandlerMapping#setUrlPathHelper
406    */
407   public ProxyHttpRequestHandler setUrlPathHelper(UrlPathHelper urlPathHelper)
408   {
409     Assert.notNull(urlPathHelper, "UrlPathHelper must not be null");
410     this.urlPathHelper = urlPathHelper;
411     return this;
412   }
413 }