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     LOG.debug("requesting: {}", resource);
106
107     CloseableHttpResponse p_response = null;
108     try
109     {
110       HttpGet p_request = new HttpGet(resource);
111
112       p_response = client.execute(p_request);
113
114       StatusLine status = p_response.getStatusLine();
115       int code = status.getStatusCode();
116       LOG.debug("response: {} - {}", code, status.getReasonPhrase());
117
118       setCacheControl(computeCacheControl(p_response));
119       prepareResponse(response);
120
121       /** Copy some headers, if present.. */
122       ProxyHttpRequestHandler.copyHeader(p_response, response, "Date");
123       ProxyHttpRequestHandler.copyHeader(p_response, response, "ETag");
124       ProxyHttpRequestHandler.copyHeader(p_response, response, "Last-Modified");
125       ProxyHttpRequestHandler.copyHeader(p_response, response, "Content-Length");
126       ProxyHttpRequestHandler.copyHeader(p_response, response, "Content-Type");
127
128       switch (code)
129       {
130         case HttpServletResponse.SC_FOUND:
131         case HttpServletResponse.SC_OK:
132           /** OK. Continue as normal... */
133           response.setStatus(code);
134
135           HttpEntity entity = p_response.getEntity();
136
137           StreamUtils.copy(entity.getContent(), response.getOutputStream());
138
139           /** Release the connection */
140           EntityUtils.consume(entity);
141           p_response.close();
142
143           return;
144
145         case HttpServletResponse.SC_NOT_FOUND:
146         case HttpServletResponse.SC_GONE:
147           /** The resource can not be resolved through this origin */
148           response.sendError(code, status.getReasonPhrase());
149           return;
150
151         case HttpServletResponse.SC_MOVED_PERMANENTLY:
152           // TODO
153
154         case HttpServletResponse.SC_SEE_OTHER:
155           // TODO
156
157         case HttpServletResponse.SC_TEMPORARY_REDIRECT:
158           // TODO
159
160         default:
161           LOG.error("{} -- {}", p_response.getStatusLine(), resource);
162           // TODO: throw sensible exceptions, to communicate resolving-errors
163           throw new RuntimeException(p_response.getStatusLine().toString() + " -- " + resource);
164       }
165     }
166     catch (IOException e)
167     {
168       LOG.error("unexpected error while resolving {}: {}", resource, e.getMessage());
169       // TODO: throw sensible exceptions, to communicate resolving-errors
170       throw new RuntimeException(e);
171     }
172     finally
173     {
174       if (p_response != null)
175       {
176         EntityUtils.consumeQuietly(p_response.getEntity());
177         p_response.close();
178       }
179     }
180   }
181
182   public static void copyHeader(
183       HttpResponse source,
184       HttpServletResponse target,
185       String name
186       )
187   {
188     Header header = source.getLastHeader(name);
189     if (header != null)
190     {
191       LOG.trace("copy header {}: {}", header.getName(), header.getValue());
192       target.addHeader(header.getName(), header.getValue());
193     }
194   }
195
196   public CacheControl computeCacheControl(HttpResponse response)
197   {
198     if (ttl == null)
199       /** Caching is disabled! */
200       return CacheControl.noStore();
201
202
203     boolean has_cache_control = false;
204     boolean is_public = false;
205     boolean is_private = false;
206     boolean no_cache = false;
207     boolean no_store = false;
208     boolean no_transform = false;
209     boolean must_revalidate = false;
210     boolean proxy_revalidate = false;
211     Long max_age = null;
212     Long s_maxage = null;
213
214     for (Header header : response.getHeaders("Cache-Control"))
215     {
216       has_cache_control = true;
217       for (HeaderElement element : header.getElements())
218       {
219         switch (element.getName())
220         {
221           case "public":
222             is_public = true;
223             break;
224           case "private":
225             is_private = true;
226             break;
227           case "no-cache":
228             no_cache = true;
229             break;
230           case "no-store":
231             no_store = true;
232             break;
233           case "no_transform":
234             no_transform = true;
235             break;
236           case "must-revalidate":
237             must_revalidate = true;
238             break;
239           case "proxy-revalidate":
240             proxy_revalidate = true;
241           case "max-age":
242             try
243             {
244               max_age = Long.parseLong(element.getValue());
245             }
246             catch (NumberFormatException e)
247             {
248               LOG.warn(
249                   "invalid header \"Cache-Control: max-age={}\"",
250                   element.getValue()
251                   );
252             }
253             break;
254           case "s-maxage":
255             try
256             {
257               s_maxage = Long.parseLong(element.getValue());
258             }
259             catch (NumberFormatException e)
260             {
261               LOG.warn(
262                   "invalid header \"Cache-Control: s-maxage={}\"",
263                   element.getValue()
264                   );
265             }
266             break;
267           default:
268               LOG.warn(
269                   "invalid header \"Cache-Control: {}{}\"",
270                   element.getName(),
271                   element.getValue() == null ? "" : "=" + element.getValue()
272                   );
273         }
274       }
275     }
276
277     if (!has_cache_control)
278     {
279       Header header = response.getLastHeader("Expires");
280       if (header == null)
281         /** No TTL specified in response-headers: use configured default */
282         max_age = ttl;
283       else
284         try
285         {
286           OffsetDateTime expires
287               = OffsetDateTime.parse(
288                   header.getValue(),
289                   DateTimeFormatter.RFC_1123_DATE_TIME
290               );
291
292           Duration delta = Duration.between(OffsetDateTime.now(clock), expires);
293           if (delta.isNegative())
294             no_store = true;
295           else
296             max_age = delta.getSeconds();
297         }
298         catch (DateTimeParseException e)
299         {
300           LOG.warn("invalid header \"Expires: {}\"", header.getValue());
301           /**
302            * No TTL specified in response-headers: assume expired
303            * see: https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.21
304            */
305           max_age = 0l;
306         }
307     }
308
309     CacheControl cache_control;
310
311     if (no_store)
312       cache_control = CacheControl.noStore();
313     else if (no_cache)
314       cache_control = CacheControl.noCache();
315     else if (max_age != null)
316       cache_control = CacheControl.maxAge(max_age, TimeUnit.SECONDS);
317     else
318       cache_control = CacheControl.empty();
319
320     if (is_private)
321       cache_control.cachePrivate();
322     if (is_public)
323       cache_control.cachePublic();
324     if (no_transform)
325       cache_control.noTransform();
326     if (must_revalidate)
327       cache_control.mustRevalidate();
328     if (proxy_revalidate)
329       cache_control.proxyRevalidate();
330     if (s_maxage != null)
331       cache_control.sMaxAge(s_maxage, TimeUnit.SECONDS);
332
333     return cache_control;
334   }
335
336
337   public ProxyHttpRequestHandler setClient(CloseableHttpClient client)
338   {
339     this.client = client;
340     return this;
341   }
342
343   public ProxyHttpRequestHandler setOrigin(String origin)
344   {
345     if (origin.endsWith("/"))
346       this.origin = origin;
347     else
348       this.origin = origin + "/";
349     return this;
350   }
351
352   public ProxyHttpRequestHandler setTtl(Long ttl)
353   {
354     this.ttl = ttl;
355     return this;
356   }
357
358   /**
359    * Set if URL lookup should always use full path within current servlet
360    * context. Else, the path within the current servlet mapping is used
361    * if applicable (i.e. in the case of a ".../*" servlet mapping in web.xml).
362    * Default is "false".
363    *
364    * @see org.springframework.web.util.UrlPathHelper#setAlwaysUseFullPath
365    */
366   public ProxyHttpRequestHandler setAlwaysUseFullPath(boolean alwaysUseFullPath)
367   {
368     this.urlPathHelper.setAlwaysUseFullPath(alwaysUseFullPath);
369     return this;
370   }
371
372   /**
373    * Set if context path and request URI should be URL-decoded.
374    * Both are returned <i>undecoded</i> by the Servlet API,
375    * in contrast to the servlet path.
376    * <p>
377    * Uses either the request encoding or the default encoding according
378    * to the Servlet spec (ISO-8859-1).
379    *
380    * @see org.springframework.web.util.UrlPathHelper#setUrlDecode
381    */
382   public ProxyHttpRequestHandler setUrlDecode(boolean urlDecode)
383   {
384     this.urlPathHelper.setUrlDecode(urlDecode);
385     return this;
386   }
387
388   /**
389    * Set if ";" (semicolon) content should be stripped from the request URI.
390    *
391    * @see
392    * org.springframework.web.util.UrlPathHelper#setRemoveSemicolonContent(boolean)
393    */
394   public ProxyHttpRequestHandler setRemoveSemicolonContent(boolean removeSemicolonContent)
395   {
396     this.urlPathHelper.setRemoveSemicolonContent(removeSemicolonContent);
397     return this;
398   }
399
400   /**
401    * Set the UrlPathHelper to use for the resolution of lookup paths.
402    * <p>
403    * Use this to override the default UrlPathHelper with a custom subclass,
404    * or to share common UrlPathHelper settings across multiple
405    * MethodNameResolvers
406    * and HandlerMappings.
407    *
408    * @see
409    * org.springframework.web.servlet.handler.AbstractUrlHandlerMapping#setUrlPathHelper
410    */
411   public ProxyHttpRequestHandler setUrlPathHelper(UrlPathHelper urlPathHelper)
412   {
413     Assert.notNull(urlPathHelper, "UrlPathHelper must not be null");
414     this.urlPathHelper = urlPathHelper;
415     return this;
416   }
417 }