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}