All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.jgrapes.http.ResponseCreationSupport Maven / Gradle / Ivy

The newest version!
/*
 * JGrapes Event Driven Framework
 * Copyright (C) 2018 Michael N. Lipp
 * 
 * This program is free software; you can redistribute it and/or modify it 
 * under the terms of the GNU Affero General Public License as published by 
 * the Free Software Foundation; either version 3 of the License, or 
 * (at your option) any later version.
 * 
 * This program is distributed in the hope that it will be useful, but 
 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 
 * for more details.
 * 
 * You should have received a copy of the GNU Affero General Public License along 
 * with this program; if not, see .
 */

package org.jgrapes.http;

import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.JarURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.net.URLConnection;
import java.nio.file.FileSystemNotFoundException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.ParseException;
import java.time.Instant;
import java.time.temporal.ChronoField;
import java.util.Optional;
import java.util.function.Function;
import java.util.jar.JarEntry;
import java.util.regex.Pattern;
import org.jdrupes.httpcodec.protocols.http.HttpConstants.HttpStatus;
import org.jdrupes.httpcodec.protocols.http.HttpField;
import org.jdrupes.httpcodec.protocols.http.HttpRequest;
import org.jdrupes.httpcodec.protocols.http.HttpResponse;
import org.jdrupes.httpcodec.types.CacheControlDirectives;
import org.jdrupes.httpcodec.types.Converters;
import org.jdrupes.httpcodec.types.Directive;
import org.jdrupes.httpcodec.types.MediaType;
import org.jgrapes.core.Event;
import org.jgrapes.http.events.Request;
import org.jgrapes.http.events.Response;
import org.jgrapes.io.IOSubchannel;
import org.jgrapes.io.events.Output;
import org.jgrapes.io.util.InputStreamPipeline;

/**
 * Provides methods that support the creation of a {@link Response} events.
 */
@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
    "PMD.AbstractClassWithoutAbstractMethod" })
public abstract class ResponseCreationSupport {

    /** A default implementation for the max-age calculator. */
    @SuppressWarnings("PMD.LongVariable")
    public static final MaxAgeCalculator DEFAULT_MAX_AGE_CALCULATOR
        = new DefaultMaxAgeCalculator();

    /**
     * Send a response to the given request with the given status code 
     * and reason phrase, including a `text/plain` body with the status 
     * code and reason phrase. 
     *
     * @param request the request
     * @param channel for responding; events will be sent using
     * {@link IOSubchannel#respond(org.jgrapes.core.Event)}
     * @param statusCode the status code to send
     * @param reasonPhrase the reason phrase to send
     */
    @SuppressWarnings("PMD.EmptyCatchBlock")
    public static void sendResponse(HttpRequest request,
            IOSubchannel channel, int statusCode, String reasonPhrase) {
        HttpResponse response = request.response().get();
        response.setStatusCode(statusCode).setReasonPhrase(reasonPhrase)
            .setHasPayload(true).setField(
                HttpField.CONTENT_TYPE,
                MediaType.builder().setType("text", "plain")
                    .setParameter("charset", "utf-8").build());
        // Act like a sub-component, i.e. generate events that are
        // handled by this HTTP server as if sent from a sub-component.
        channel.respond(new Response(response));
        try {
            channel.respond(Output.from((statusCode + " " + reasonPhrase)
                .getBytes("utf-8"), true));
        } catch (UnsupportedEncodingException e) {
            // Supported by definition
        }
    }

    /**
     * Shorthand for invoking 
     * {@link #sendResponse(HttpRequest, IOSubchannel, int, String)}
     * with a predefined HTTP status.
     *
     * @param request the request
     * @param channel the channel
     * @param status the status
     */
    public static void sendResponse(HttpRequest request,
            IOSubchannel channel, HttpStatus status) {
        sendResponse(request, channel, status.statusCode(),
            status.reasonPhrase());
    }

    /**
     * Creates and sends a response with static content. The content 
     * is looked up by invoking the resolver with the path from the request.
     * 
     * The response includes a max-age header with a default value of
     * 600. The value may be modified by specifying validity infos.
     *
     * @param request the request
     * @param channel the channel
     * @param resolver the resolver
     * @param maxAgeCalculator the max age calculator, if `null`
     * the default calculator is used.
     * @return `true` if a response was sent
     */
    @SuppressWarnings({ "PMD.NcssCount", "PMD.UseStringBufferForStringAppends",
        "PMD.ExceptionAsFlowControl" })
    public static boolean sendStaticContent(
            HttpRequest request, IOSubchannel channel,
            Function resolver, MaxAgeCalculator maxAgeCalculator) {
        String path = request.requestUri().getPath();
        URL resourceUrl = resolver.apply(path);
        ResourceInfo info;
        URLConnection resConn;
        @SuppressWarnings("PMD.CloseResource")
        InputStream resIn;
        try {
            if (resourceUrl == null) {
                throw new IOException();
            }
            info = resourceInfo(resourceUrl);
            if (Boolean.TRUE.equals(info.isDirectory())) {
                throw new IOException();
            }
            resConn = resourceUrl.openConnection();
            resIn = resConn.getInputStream();
        } catch (IOException e1) {
            try {
                if (!path.endsWith("/")) {
                    path += "/";
                }
                path += "index.html";
                resourceUrl = resolver.apply(path);
                if (resourceUrl == null) {
                    return false;
                }
                info = resourceInfo(resourceUrl);
                resConn = resourceUrl.openConnection();
                resIn = resConn.getInputStream();
            } catch (IOException e2) {
                return false;
            }
        }
        HttpResponse response = request.response().get();
        response.setField(HttpField.LAST_MODIFIED,
            Optional.ofNullable(info.getLastModifiedAt())
                .orElseGet(Instant::now));

        // Get content type and derive max age
        MediaType mediaType = HttpResponse.contentType(uriFromUrl(resourceUrl));
        setMaxAge(response,
            (maxAgeCalculator == null ? DEFAULT_MAX_AGE_CALCULATOR
                : maxAgeCalculator).maxAge(request, mediaType));

        // Check if sending is really required.
        Optional modifiedSince = request
            .findValue(HttpField.IF_MODIFIED_SINCE, Converters.DATE_TIME);
        if (modifiedSince.isPresent() && info.getLastModifiedAt() != null
            && !info.getLastModifiedAt().isAfter(modifiedSince.get())) {
            response.setStatus(HttpStatus.NOT_MODIFIED);
            channel.respond(new Response(response));
        } else {
            response.setContentType(mediaType);
            response.setStatus(HttpStatus.OK);
            channel.respond(new Response(response));
            // Start sending content (Output events as resonses)
            new InputStreamPipeline(resIn, channel).suppressClosed().run();
        }
        return true;
    }

    /**
     * Shorthand for invoking 
     * {@link #sendStaticContent(HttpRequest, IOSubchannel, Function, MaxAgeCalculator)}
     * with the {@link HttpRequest} from the event. Also sets the result
     * of the event to `true` and invokes {@link Event#stop()} 
     * if a response was sent.
     *
     * @param event the event
     * @param channel the channel
     * @param resolver the resolver
     * @param maxAgeCalculator the max age calculator, if `null`
     * the default calculator is used.
     * @return `true` if a response was sent
     * @throws ParseException the parse exception
     */
    public static boolean sendStaticContent(
            Request.In event, IOSubchannel channel,
            Function resolver, MaxAgeCalculator maxAgeCalculator) {
        if (sendStaticContent(
            event.httpRequest(), channel, resolver, maxAgeCalculator)) {
            event.setResult(true);
            event.stop();
            return true;
        }
        return false;
    }

    /**
     * Combines the known information about a resource.
     */
    @SuppressWarnings("PMD.DataClass")
    public static class ResourceInfo {
        public Boolean isDirectory;
        public Instant lastModifiedAt;

        /**
         * @param isDirectory
         * @param lastModifiedAt
         */
        public ResourceInfo(Boolean isDirectory, Instant lastModifiedAt) {
            this.isDirectory = isDirectory;
            this.lastModifiedAt = lastModifiedAt;
        }

        /**
         * @return the isDirectory
         */
        public Boolean isDirectory() {
            return isDirectory;
        }

        /**
         * @return the lastModifiedAt
         */
        public Instant getLastModifiedAt() {
            return lastModifiedAt;
        }
    }

    /**
     * Attempts to lookup the additional resource information for the
     * given URL. 
     * 
     * If a {@link URL} references a file, it is easy to find out if 
     * the resource referenced is a directory and to get its last 
     * modification time. Getting the same information
     * for a {@link URL} that references resources in a jar is a bit
     * more difficult. This method handles both cases.
     *
     * @param resource the resource URL
     * @return the resource info
     */
    @SuppressWarnings({ "PMD.EmptyCatchBlock",
        "PMD.AvoidLiteralsInIfCondition" })
    public static ResourceInfo resourceInfo(URL resource) {
        try {
            Path path = Paths.get(resource.toURI());
            return new ResourceInfo(Files.isDirectory(path),
                Files.getLastModifiedTime(path).toInstant()
                    .with(ChronoField.NANO_OF_SECOND, 0));
        } catch (FileSystemNotFoundException | IOException
                | URISyntaxException e) {
            // Fall through
        }
        if ("jar".equals(resource.getProtocol())) {
            try {
                JarURLConnection conn
                    = (JarURLConnection) resource.openConnection();
                JarEntry entry = conn.getJarEntry();
                return new ResourceInfo(entry.isDirectory(),
                    entry.getLastModifiedTime().toInstant()
                        .with(ChronoField.NANO_OF_SECOND, 0));
            } catch (IOException e) {
                // Fall through
            }
        }
        try {
            URLConnection conn = resource.openConnection();
            long lastModified = conn.getLastModified();
            if (lastModified != 0) {
                return new ResourceInfo(null, Instant.ofEpochMilli(
                    lastModified).with(ChronoField.NANO_OF_SECOND, 0));
            }
        } catch (IOException e) {
            // Fall through
        }
        return new ResourceInfo(null, null);
    }

    /**
     * Create a {@link URI} from a path. This is similar to calling
     * `new URI(null, null, path, null)` with the {@link URISyntaxException}
     * converted to a {@link IllegalArgumentException}.
     * 
     * @param path the path
     * @return the uri
     * @throws IllegalArgumentException if the string violates 
     * RFC 2396
     */
    @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures")
    public static URI uriFromPath(String path) throws IllegalArgumentException {
        try {
            return new URI(null, null, path, null);
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Create a {@link URI} from a {@link URL}. This is similar to calling
     * `url.toURI()` with the {@link URISyntaxException}
     * converted to a {@link IllegalArgumentException}.
     * 
     * @param url the url
     * @return the uri
     * @throws IllegalArgumentException if the url violates RFC 2396
     */
    @SuppressWarnings("PMD.AvoidUncheckedExceptionsInSignatures")
    public static URI uriFromUrl(URL url) throws IllegalArgumentException {
        try {
            return url.toURI();
        } catch (URISyntaxException e) {
            throw new IllegalArgumentException(e);
        }
    }

    /**
     * Sets the cache control header in the given response.
     *
     * @param response the response
     * @param maxAge the max age
     * @return the value set
     */
    public static long setMaxAge(HttpResponse response, int maxAge) {
        CacheControlDirectives directives = new CacheControlDirectives();
        directives.add(new Directive("max-age", maxAge));
        response.setField(HttpField.CACHE_CONTROL, directives);
        return maxAge;
    }

    /**
     * Describes a calculator for the max-age property.
     */
    @FunctionalInterface
    public interface MaxAgeCalculator {

        /**
         * Calculate a max age value for a response using the given 
         * request and the media type of the repsonse.
         *
         * @param request the request, usually only the URI is
         * considered for the calculation
         * @param mediaType the media type of the response
         * @return the max age value to be used in the response
         */
        int maxAge(HttpRequest request, MediaType mediaType);
    }

    /**
     * DefaultMaxAgeCalculator provides an implementation that 
     * tries to guess a good max age value by looking at the
     * path of the requested resource. If the path contains
     * the pattern "dash, followed by a number, followed by
     * a dot and a number" it is assumed that the resource
     * is versioned, i.e. its path changes if the resource
     * changes. In this case a max age of one year is returned.
     * In all other cases, a max age value of 60 (one minute)
     * is returned.
     */
    public static class DefaultMaxAgeCalculator implements MaxAgeCalculator {

        public static final Pattern VERSION_PATTERN
            = Pattern.compile("-[0-9]+\\.[0-9]+");

        @Override
        public int maxAge(HttpRequest request, MediaType mediaType) {
            if (VERSION_PATTERN.matcher(
                request.requestUri().getPath()).find()) {
                return 365 * 24 * 3600;
            }
            return 60;
        }

    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy