001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2018 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.http; 020 021import java.io.IOException; 022import java.io.InputStream; 023import java.io.UnsupportedEncodingException; 024import java.net.JarURLConnection; 025import java.net.URI; 026import java.net.URISyntaxException; 027import java.net.URL; 028import java.net.URLConnection; 029import java.nio.file.FileSystemNotFoundException; 030import java.nio.file.Files; 031import java.nio.file.Path; 032import java.nio.file.Paths; 033import java.text.ParseException; 034import java.time.Instant; 035import java.time.temporal.ChronoField; 036import java.util.Optional; 037import java.util.function.Function; 038import java.util.jar.JarEntry; 039import java.util.regex.Pattern; 040import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus; 041import org.jdrupes.httpcodec.protocols.http.HttpField; 042import org.jdrupes.httpcodec.protocols.http.HttpRequest; 043import org.jdrupes.httpcodec.protocols.http.HttpResponse; 044import org.jdrupes.httpcodec.types.CacheControlDirectives; 045import org.jdrupes.httpcodec.types.Converters; 046import org.jdrupes.httpcodec.types.Directive; 047import org.jdrupes.httpcodec.types.MediaType; 048import org.jgrapes.core.Event; 049import org.jgrapes.http.events.Request; 050import org.jgrapes.http.events.Response; 051import org.jgrapes.io.IOSubchannel; 052import org.jgrapes.io.events.Output; 053import org.jgrapes.io.util.InputStreamPipeline; 054 055/** 056 * Provides methods that support the creation of a {@link Response} events. 057 */ 058@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", 059 "PMD.AbstractClassWithoutAbstractMethod" }) 060public abstract class ResponseCreationSupport { 061 062 /** A default implementation for the max-age calculator. */ 063 @SuppressWarnings("PMD.LongVariable") 064 public static final MaxAgeCalculator DEFAULT_MAX_AGE_CALCULATOR 065 = new DefaultMaxAgeCalculator(); 066 067 /** 068 * Send a response to the given request with the given status code 069 * and reason phrase, including a `text/plain` body with the status 070 * code and reason phrase. 071 * 072 * @param request the request 073 * @param channel for responding; events will be sent using 074 * {@link IOSubchannel#respond(org.jgrapes.core.Event)} 075 * @param statusCode the status code to send 076 * @param reasonPhrase the reason phrase to send 077 */ 078 @SuppressWarnings("PMD.EmptyCatchBlock") 079 public static void sendResponse(HttpRequest request, 080 IOSubchannel channel, int statusCode, String reasonPhrase) { 081 HttpResponse response = request.response().get(); 082 response.setStatusCode(statusCode).setReasonPhrase(reasonPhrase) 083 .setHasPayload(true).setField( 084 HttpField.CONTENT_TYPE, 085 MediaType.builder().setType("text", "plain") 086 .setParameter("charset", "utf-8").build()); 087 // Act like a sub-component, i.e. generate events that are 088 // handled by this HTTP server as if sent from a sub-component. 089 channel.respond(new Response(response)); 090 try { 091 channel.respond(Output.from((statusCode + " " + reasonPhrase) 092 .getBytes("utf-8"), true)); 093 } catch (UnsupportedEncodingException e) { 094 // Supported by definition 095 } 096 } 097 098 /** 099 * Shorthand for invoking 100 * {@link #sendResponse(HttpRequest, IOSubchannel, int, String)} 101 * with a predefined HTTP status. 102 * 103 * @param request the request 104 * @param channel the channel 105 * @param status the status 106 */ 107 public static void sendResponse(HttpRequest request, 108 IOSubchannel channel, HttpStatus status) { 109 sendResponse(request, channel, status.statusCode(), 110 status.reasonPhrase()); 111 } 112 113 /** 114 * Creates and sends a response with static content. The content 115 * is looked up by invoking the resolver with the path from the request. 116 * 117 * The response includes a max-age header with a default value of 118 * 600. The value may be modified by specifying validity infos. 119 * 120 * @param request the request 121 * @param channel the channel 122 * @param resolver the resolver 123 * @param maxAgeCalculator the max age calculator, if `null` 124 * the default calculator is used. 125 * @return `true` if a response was sent 126 */ 127 @SuppressWarnings({ "PMD.NcssCount", "PMD.UseStringBufferForStringAppends", 128 "PMD.ExceptionAsFlowControl" }) 129 public static boolean sendStaticContent( 130 HttpRequest request, IOSubchannel channel, 131 Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) { 132 String path = request.requestUri().getPath(); 133 URL resourceUrl = resolver.apply(path); 134 ResourceInfo info; 135 URLConnection resConn; 136 @SuppressWarnings("PMD.CloseResource") 137 InputStream resIn; 138 try { 139 if (resourceUrl == null) { 140 throw new IOException(); 141 } 142 info = resourceInfo(resourceUrl); 143 if (Boolean.TRUE.equals(info.isDirectory())) { 144 throw new IOException(); 145 } 146 resConn = resourceUrl.openConnection(); 147 resIn = resConn.getInputStream(); 148 } catch (IOException e1) { 149 try { 150 if (!path.endsWith("/")) { 151 path += "/"; 152 } 153 path += "index.html"; 154 resourceUrl = resolver.apply(path); 155 if (resourceUrl == null) { 156 return false; 157 } 158 info = resourceInfo(resourceUrl); 159 resConn = resourceUrl.openConnection(); 160 resIn = resConn.getInputStream(); 161 } catch (IOException e2) { 162 return false; 163 } 164 } 165 HttpResponse response = request.response().get(); 166 response.setField(HttpField.LAST_MODIFIED, 167 Optional.ofNullable(info.getLastModifiedAt()) 168 .orElseGet(Instant::now)); 169 170 // Get content type and derive max age 171 MediaType mediaType = HttpResponse.contentType(uriFromUrl(resourceUrl)); 172 setMaxAge(response, 173 (maxAgeCalculator == null ? DEFAULT_MAX_AGE_CALCULATOR 174 : maxAgeCalculator).maxAge(request, mediaType)); 175 176 // Check if sending is really required. 177 Optional<Instant> modifiedSince = request 178 .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME); 179 if (modifiedSince.isPresent() && info.getLastModifiedAt() != null 180 && !info.getLastModifiedAt().isAfter(modifiedSince.get())) { 181 response.setStatus(HttpStatus.NOT_MODIFIED); 182 channel.respond(new Response(response)); 183 } else { 184 response.setContentType(mediaType); 185 response.setStatus(HttpStatus.OK); 186 channel.respond(new Response(response)); 187 // Start sending content (Output events as resonses) 188 new InputStreamPipeline(resIn, channel).suppressClosed().run(); 189 } 190 return true; 191 } 192 193 /** 194 * Shorthand for invoking 195 * {@link #sendStaticContent(HttpRequest, IOSubchannel, Function, MaxAgeCalculator)} 196 * with the {@link HttpRequest} from the event. Also sets the result 197 * of the event to `true` and invokes {@link Event#stop()} 198 * if a response was sent. 199 * 200 * @param event the event 201 * @param channel the channel 202 * @param resolver the resolver 203 * @param maxAgeCalculator the max age calculator, if `null` 204 * the default calculator is used. 205 * @return `true` if a response was sent 206 * @throws ParseException the parse exception 207 */ 208 public static boolean sendStaticContent( 209 Request.In event, IOSubchannel channel, 210 Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) { 211 if (sendStaticContent( 212 event.httpRequest(), channel, resolver, maxAgeCalculator)) { 213 event.setResult(true); 214 event.stop(); 215 return true; 216 } 217 return false; 218 } 219 220 /** 221 * Combines the known information about a resource. 222 */ 223 @SuppressWarnings("PMD.DataClass") 224 public static class ResourceInfo { 225 public Boolean isDirectory; 226 public Instant lastModifiedAt; 227 228 /** 229 * @param isDirectory 230 * @param lastModifiedAt 231 */ 232 public ResourceInfo(Boolean isDirectory, Instant lastModifiedAt) { 233 this.isDirectory = isDirectory; 234 this.lastModifiedAt = lastModifiedAt; 235 } 236 237 /** 238 * @return the isDirectory 239 */ 240 public Boolean isDirectory() { 241 return isDirectory; 242 } 243 244 /** 245 * @return the lastModifiedAt 246 */ 247 public Instant getLastModifiedAt() { 248 return lastModifiedAt; 249 } 250 } 251 252 /** 253 * Attempts to lookup the additional resource information for the 254 * given URL. 255 * 256 * If a {@link URL} references a file, it is easy to find out if 257 * the resource referenced is a directory and to get its last 258 * modification time. Getting the same information 259 * for a {@link URL} that references resources in a jar is a bit 260 * more difficult. This method handles both cases. 261 * 262 * @param resource the resource URL 263 * @return the resource info 264 */ 265 @SuppressWarnings({ "PMD.EmptyCatchBlock", 266 "PMD.AvoidLiteralsInIfCondition" }) 267 public static ResourceInfo resourceInfo(URL resource) { 268 try { 269 Path path = Paths.get(resource.toURI()); 270 return new ResourceInfo(Files.isDirectory(path), 271 Files.getLastModifiedTime(path).toInstant() 272 .with(ChronoField.NANO_OF_SECOND, 0)); 273 } catch (FileSystemNotFoundException | IOException 274 | URISyntaxException e) { 275 // Fall through 276 } 277 if ("jar".equals(resource.getProtocol())) { 278 try { 279 JarURLConnection conn 280 = (JarURLConnection) resource.openConnection(); 281 JarEntry entry = conn.getJarEntry(); 282 return new ResourceInfo(entry.isDirectory(), 283 entry.getLastModifiedTime().toInstant() 284 .with(ChronoField.NANO_OF_SECOND, 0)); 285 } catch (IOException e) { 286 // Fall through 287 } 288 } 289 try { 290 URLConnection conn = resource.openConnection(); 291 long lastModified = conn.getLastModified(); 292 if (lastModified != 0) { 293 return new ResourceInfo(null, Instant.ofEpochMilli( 294 lastModified).with(ChronoField.NANO_OF_SECOND, 0)); 295 } 296 } catch (IOException e) { 297 // Fall through 298 } 299 return new ResourceInfo(null, null); 300 } 301 302 /** 303 * Create a {@link URI} from a path. This is similar to calling 304 * `new URI(null, null, path, null)` with the {@link URISyntaxException} 305 * converted to a {@link IllegalArgumentException}. 306 * 307 * @param path the path 308 * @return the uri 309 * @throws IllegalArgumentException if the string violates 310 * RFC 2396 311 */ 312 @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") 313 public static URI uriFromPath(String path) throws IllegalArgumentException { 314 try { 315 return new URI(null, null, path, null); 316 } catch (URISyntaxException e) { 317 throw new IllegalArgumentException(e); 318 } 319 } 320 321 /** 322 * Create a {@link URI} from a {@link URL}. This is similar to calling 323 * `url.toURI()` with the {@link URISyntaxException} 324 * converted to a {@link IllegalArgumentException}. 325 * 326 * @param url the url 327 * @return the uri 328 * @throws IllegalArgumentException if the url violates RFC 2396 329 */ 330 @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures") 331 public static URI uriFromUrl(URL url) throws IllegalArgumentException { 332 try { 333 return url.toURI(); 334 } catch (URISyntaxException e) { 335 throw new IllegalArgumentException(e); 336 } 337 } 338 339 /** 340 * Sets the cache control header in the given response. 341 * 342 * @param response the response 343 * @param maxAge the max age 344 * @return the value set 345 */ 346 public static long setMaxAge(HttpResponse response, int maxAge) { 347 CacheControlDirectives directives = new CacheControlDirectives(); 348 directives.add(new Directive("max-age", maxAge)); 349 response.setField(HttpField.CACHE_CONTROL, directives); 350 return maxAge; 351 } 352 353 /** 354 * Describes a calculator for the max-age property. 355 */ 356 @FunctionalInterface 357 public interface MaxAgeCalculator { 358 359 /** 360 * Calculate a max age value for a response using the given 361 * request and the media type of the repsonse. 362 * 363 * @param request the request, usually only the URI is 364 * considered for the calculation 365 * @param mediaType the media type of the response 366 * @return the max age value to be used in the response 367 */ 368 int maxAge(HttpRequest request, MediaType mediaType); 369 } 370 371 /** 372 * DefaultMaxAgeCalculator provides an implementation that 373 * tries to guess a good max age value by looking at the 374 * path of the requested resource. If the path contains 375 * the pattern "dash, followed by a number, followed by 376 * a dot and a number" it is assumed that the resource 377 * is versioned, i.e. its path changes if the resource 378 * changes. In this case a max age of one year is returned. 379 * In all other cases, a max age value of 60 (one minute) 380 * is returned. 381 */ 382 public static class DefaultMaxAgeCalculator implements MaxAgeCalculator { 383 384 public static final Pattern VERSION_PATTERN 385 = Pattern.compile("-[0-9]+\\.[0-9]+"); 386 387 @Override 388 public int maxAge(HttpRequest request, MediaType mediaType) { 389 if (VERSION_PATTERN.matcher( 390 request.requestUri().getPath()).find()) { 391 return 365 * 24 * 3600; 392 } 393 return 60; 394 } 395 396 } 397}