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