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",
128        "PMD.UseStringBufferForStringAppends" })
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 = ResponseCreationSupport.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 = ResponseCreationSupport.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(
172            ResponseCreationSupport.uriFromUrl(resourceUrl));
173        setMaxAge(response,
174            (maxAgeCalculator == null ? DEFAULT_MAX_AGE_CALCULATOR
175                : maxAgeCalculator).maxAge(request, mediaType));
176
177        // Check if sending is really required.
178        Optional<Instant> modifiedSince = request
179            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
180        if (modifiedSince.isPresent() && info.getLastModifiedAt() != null
181            && !info.getLastModifiedAt().isAfter(modifiedSince.get())) {
182            response.setStatus(HttpStatus.NOT_MODIFIED);
183            channel.respond(new Response(response));
184        } else {
185            response.setContentType(mediaType);
186            response.setStatus(HttpStatus.OK);
187            channel.respond(new Response(response));
188            // Start sending content (Output events as resonses)
189            (new InputStreamPipeline(resIn, channel).suppressClosed()).run();
190        }
191        return true;
192    }
193
194    /**
195     * Shorthand for invoking 
196     * {@link #sendStaticContent(HttpRequest, IOSubchannel, Function, MaxAgeCalculator)}
197     * with the {@link HttpRequest} from the event. Also sets the result
198     * of the event to `true` and invokes {@link Event#stop()} 
199     * if a response was sent.
200     *
201     * @param event the event
202     * @param channel the channel
203     * @param resolver the resolver
204     * @param maxAgeCalculator the max age calculator, if `null`
205     * the default calculator is used.
206     * @return `true` if a response was sent
207     * @throws ParseException the parse exception
208     */
209    public static boolean sendStaticContent(
210            Request.In event, IOSubchannel channel,
211            Function<String, URL> resolver, MaxAgeCalculator maxAgeCalculator) {
212        if (sendStaticContent(
213            event.httpRequest(), channel, resolver, maxAgeCalculator)) {
214            event.setResult(true);
215            event.stop();
216            return true;
217        }
218        return false;
219    }
220
221    /**
222     * Combines the known information about a resource.
223     */
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    public static ResourceInfo resourceInfo(URL resource) {
267        try {
268            Path path = Paths.get(resource.toURI());
269            return new ResourceInfo(Files.isDirectory(path),
270                Files.getLastModifiedTime(path).toInstant()
271                    .with(ChronoField.NANO_OF_SECOND, 0));
272        } catch (FileSystemNotFoundException | IOException
273                | URISyntaxException e) {
274            // Fall through
275        }
276        if ("jar".equals(resource.getProtocol())) {
277            try {
278                JarURLConnection conn
279                    = (JarURLConnection) resource.openConnection();
280                JarEntry entry = conn.getJarEntry();
281                return new ResourceInfo(entry.isDirectory(),
282                    entry.getLastModifiedTime().toInstant()
283                        .with(ChronoField.NANO_OF_SECOND, 0));
284            } catch (IOException e) {
285                // Fall through
286            }
287        }
288        try {
289            URLConnection conn = resource.openConnection();
290            long lastModified = conn.getLastModified();
291            if (lastModified != 0) {
292                return new ResourceInfo(null, Instant.ofEpochMilli(
293                    lastModified).with(ChronoField.NANO_OF_SECOND, 0));
294            }
295        } catch (IOException e) {
296            // Fall through
297        }
298        return new ResourceInfo(null, null);
299    }
300
301    /**
302     * Create a {@link URI} from a path. This is similar to calling
303     * `new URI(null, null, path, null)` with the {@link URISyntaxException}
304     * converted to a {@link IllegalArgumentException}.
305     * 
306     * @param path the path
307     * @return the uri
308     * @throws IllegalArgumentException if the string violates 
309     * RFC 2396
310     */
311    @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures")
312    public static URI uriFromPath(String path) throws IllegalArgumentException {
313        try {
314            return new URI(null, null, path, null);
315        } catch (URISyntaxException e) {
316            throw new IllegalArgumentException(e);
317        }
318    }
319
320    /**
321     * Create a {@link URI} from a {@link URL}. This is similar to calling
322     * `url.toURI()` with the {@link URISyntaxException}
323     * converted to a {@link IllegalArgumentException}.
324     * 
325     * @param url the url
326     * @return the uri
327     * @throws IllegalArgumentException if the url violates RFC 2396
328     */
329    @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures")
330    public static URI uriFromUrl(URL url) throws IllegalArgumentException {
331        try {
332            return url.toURI();
333        } catch (URISyntaxException e) {
334            throw new IllegalArgumentException(e);
335        }
336    }
337
338    /**
339     * Sets the cache control header in the given response.
340     *
341     * @param response the response
342     * @param maxAge the max age
343     * @return the value set
344     */
345    public static long setMaxAge(HttpResponse response, int maxAge) {
346        CacheControlDirectives directives = new CacheControlDirectives();
347        directives.add(new Directive("max-age", maxAge));
348        response.setField(HttpField.CACHE_CONTROL, directives);
349        return maxAge;
350    }
351
352    /**
353     * Describes a calculator for the max-age property.
354     */
355    @FunctionalInterface
356    public interface MaxAgeCalculator {
357
358        /**
359         * Calculate a max age value for a response using the given 
360         * request and the media type of the repsonse.
361         *
362         * @param request the request, usually only the URI is
363         * considered for the calculation
364         * @param mediaType the media type of the response
365         * @return the max age value to be used in the response
366         */
367        int maxAge(HttpRequest request, MediaType mediaType);
368    }
369
370    /**
371     * DefaultMaxAgeCalculator provides an implementation that 
372     * tries to guess a good max age value by looking at the
373     * path of the requested resource. If the path contains
374     * the pattern "dash, followed by a number, followed by
375     * a dot and a number" it is assumed that the resource
376     * is versioned, i.e. its path changes if the resource
377     * changes. In this case a max age of one year is returned.
378     * In all other cases, a max age value of 60 (one minute)
379     * is returned.
380     */
381    public static class DefaultMaxAgeCalculator implements MaxAgeCalculator {
382
383        public static final Pattern VERSION_PATTERN
384            = Pattern.compile("-[0-9]+\\.[0-9]+");
385
386        @Override
387        public int maxAge(HttpRequest request, MediaType mediaType) {
388            if (VERSION_PATTERN.matcher(
389                request.requestUri().getPath()).find()) {
390                return 365 * 24 * 3600;
391            }
392            return 60;
393        }
394
395    }
396}