WebbUtils.java

package com.goebl.david;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URLEncoder;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
import java.util.zip.Inflater;
import java.util.zip.InflaterInputStream;

/**
 * Static utility method and tools for HTTP traffic parsing and encoding.
 *
 * @author hgoebl
 */
public class WebbUtils {

    protected WebbUtils() {}

    /**
     * Convert a Map to a query string.
     * @param values the map with the values
     *               <code>null</code> will be encoded as empty string, all other objects are converted to
     *               String by calling its <code>toString()</code> method.
     * @return e.g. "key1=value&amp;key2=&amp;email=max%40example.com"
     */
    public static String queryString(Map<String, Object> values) {
        StringBuilder sbuf = new StringBuilder();
        String separator = "";

        for (Map.Entry<String, Object> entry : values.entrySet()) {
            Object entryValue = entry.getValue();
            if (entryValue instanceof Object[]) {
                for (Object value : (Object[]) entryValue) {
                    appendParam(sbuf, separator, entry.getKey(), value);
                    separator = "&";
                }
            } else if (entryValue instanceof Iterable) {
                for (Object multiValue : (Iterable) entryValue) {
                    appendParam(sbuf, separator, entry.getKey(), multiValue);
                    separator = "&";
                }
            } else {
                appendParam(sbuf, separator, entry.getKey(), entryValue);
                separator = "&";
            }
        }

        return sbuf.toString();
    }

    private static void appendParam(StringBuilder sbuf, String separator, String entryKey, Object value) {
        String sValue = value == null ? "" : String.valueOf(value);
        sbuf.append(separator);
        sbuf.append(urlEncode(entryKey));
        sbuf.append('=');
        sbuf.append(urlEncode(sValue));
    }

    /**
     * Convert a byte array to a JSONObject.
     * @param bytes a UTF-8 encoded string representing a JSON object.
     * @return the parsed object
     * @throws WebbException in case of error (usually a parsing error due to invalid JSON)
     */
    public static JSONObject toJsonObject(byte[] bytes) {
        String json;
        try {
            json = new String(bytes, Const.UTF8);
            return new JSONObject(json);
        } catch (UnsupportedEncodingException e) {
            throw new WebbException(e);
        } catch (JSONException e) {
            throw new WebbException("payload is not a valid JSON object", e);
        }
    }

    /**
     * Convert a byte array to a JSONArray.
     * @param bytes a UTF-8 encoded string representing a JSON array.
     * @return the parsed JSON array
     * @throws WebbException in case of error (usually a parsing error due to invalid JSON)
     */
    public static JSONArray toJsonArray(byte[] bytes) {
        String json;
        try {
            json = new String(bytes, Const.UTF8);
            return new JSONArray(json);
        } catch (UnsupportedEncodingException e) {
            throw new WebbException(e);
        } catch (JSONException e) {
            throw new WebbException("payload is not a valid JSON array", e);
        }
    }

    /**
     * Read an <code>InputStream</code> into <code>byte[]</code> until EOF.
     * <br>
     * Does not close the InputStream!
     *
     * @param is the stream to read the bytes from
     * @return all read bytes as an array
     * @throws IOException when read or write operation fails
     */
    public static byte[] readBytes(InputStream is) throws IOException {
        if (is == null) {
            return null;
        }
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        copyStream(is, baos);
        return baos.toByteArray();
    }

    /**
     * Copy complete content of <code>InputStream</code> to <code>OutputStream</code> until EOF.
     * <br>
     * Does not close the InputStream nor OutputStream!
     *
     * @param input the stream to read the bytes from
     * @param output the stream to write the bytes to
     * @throws IOException when read or write operation fails
     */
    public static void copyStream(InputStream input, OutputStream output) throws IOException {
        byte[] buffer = new byte[1024];
        int count;
        while ((count = input.read(buffer)) != -1) {
            output.write(buffer, 0, count);
        }
    }

    /**
     * Creates a new instance of a <code>DateFormat</code> for RFC1123 compliant dates.
     * <br>
     * Should be stored for later use but be aware that this DateFormat is not Thread-safe!
     * <br>
     * If you have to deal with dates in this format with JavaScript, it's easy, because the JavaScript
     * Date object has a constructor for strings formatted this way.
     * @return a new instance
     */
    public static DateFormat getRfc1123DateFormat() {
        DateFormat format = new SimpleDateFormat(
                "EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH);
        format.setLenient(false);
        format.setTimeZone(TimeZone.getTimeZone("UTC"));
        return format;
    }

    static String urlEncode(String value) {
        try {
            return URLEncoder.encode(value, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            return value;
        }
    }

    static void addRequestProperties(HttpURLConnection connection, Map<String, Object> map) {
        if (map == null || map.isEmpty()) {
            return;
        }
        for (Map.Entry<String, Object> entry : map.entrySet()) {
            addRequestProperty(connection, entry.getKey(), entry.getValue());
        }
    }

    static void addRequestProperty(HttpURLConnection connection, String name, Object value) {
        if (name == null || name.length() == 0 || value == null) {
            throw new IllegalArgumentException("name and value must not be empty");
        }

        String valueAsString;
        if (value instanceof Date) {
            valueAsString = getRfc1123DateFormat().format((Date) value);
        } else if (value instanceof Calendar) {
            valueAsString = getRfc1123DateFormat().format(((Calendar) value).getTime());
        } else {
            valueAsString = value.toString();
        }

        connection.addRequestProperty(name, valueAsString);
    }

    static void ensureRequestProperty(HttpURLConnection connection, String name, Object value) {
        if (!connection.getRequestProperties().containsKey(name)) {
            addRequestProperty(connection, name, value);
        }
    }

    static byte[] getPayloadAsBytesAndSetContentType(
            HttpURLConnection connection,
            Request request,
            boolean compress,
            int jsonIndentFactor) throws JSONException, UnsupportedEncodingException {

        byte[] requestBody = null;
        String bodyStr = null;

        if (request.params != null) {
            WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.APP_FORM);
            bodyStr = WebbUtils.queryString(request.params);
        } else if (request.payload == null) {
            return null;
        } else if (request.payload instanceof JSONObject) {
            WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.APP_JSON);
            bodyStr = jsonIndentFactor >= 0
                    ? ((JSONObject) request.payload).toString(jsonIndentFactor)
                    : request.payload.toString();
        } else if (request.payload instanceof JSONArray) {
            WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.APP_JSON);
            bodyStr = jsonIndentFactor >= 0
                    ? ((JSONArray) request.payload).toString(jsonIndentFactor)
                    : request.payload.toString();
        } else if (request.payload instanceof byte[]) {
            WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.APP_BINARY);
            requestBody = (byte[]) request.payload;
        } else {
            WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.TEXT_PLAIN);
            bodyStr = request.payload.toString();
        }
        if (bodyStr != null) {
            requestBody = bodyStr.getBytes(Const.UTF8);
        }

        if (requestBody == null) {
            throw new IllegalStateException();
        }

        // only compress if the new body is smaller than uncompressed body
        if (compress && requestBody.length > Const.MIN_COMPRESSED_ADVANTAGE) {
            byte[] compressedBody = gzip(requestBody);
            if (requestBody.length - compressedBody.length > Const.MIN_COMPRESSED_ADVANTAGE) {
                requestBody = compressedBody;
                connection.setRequestProperty(Const.HDR_CONTENT_ENCODING, "gzip");
            }
        }

        connection.setFixedLengthStreamingMode(requestBody.length);

        return requestBody;
    }

    static void setContentTypeAndLengthForStreaming(
            HttpURLConnection connection,
            Request request,
            boolean compress) {

        long length;

        if (request.payload instanceof File) {
            length = compress ? -1L : ((File) request.payload).length();
        } else if (request.payload instanceof InputStream) {
            length = -1L;
        } else {
            throw new IllegalStateException();
        }

        if (length > Integer.MAX_VALUE) {
            length = -1L; // use chunked streaming mode
        }

        WebbUtils.ensureRequestProperty(connection, Const.HDR_CONTENT_TYPE, Const.APP_BINARY);
        if (length < 0) {
            connection.setChunkedStreamingMode(-1); // use default chunk size
            if (compress) {
                connection.setRequestProperty(Const.HDR_CONTENT_ENCODING, "gzip");
            }
        } else {
            connection.setFixedLengthStreamingMode((int) length);
        }
    }

    static byte[] gzip(byte[] input) {
        GZIPOutputStream gzipOS = null;
        try {
            ByteArrayOutputStream byteArrayOS = new ByteArrayOutputStream();
            gzipOS = new GZIPOutputStream(byteArrayOS);
            gzipOS.write(input);
            gzipOS.flush();
            gzipOS.close();
            gzipOS = null;
            return byteArrayOS.toByteArray();
        } catch (Exception e) {
            throw new WebbException(e);
        } finally {
            if (gzipOS != null) {
                try { gzipOS.close(); } catch (Exception ignored) {}
            }
        }
    }

    static InputStream wrapStream(String contentEncoding, InputStream inputStream) throws IOException {
        if (contentEncoding == null || "identity".equalsIgnoreCase(contentEncoding)) {
            return inputStream;
        }
        if ("gzip".equalsIgnoreCase(contentEncoding)) {
            return new GZIPInputStream(inputStream);
        }
        if ("deflate".equalsIgnoreCase(contentEncoding)) {
            return new InflaterInputStream(inputStream, new Inflater(false), 512);
        }
        throw new WebbException("unsupported content-encoding: " + contentEncoding);
    }

    static <T> void parseResponseBody(Class<T> clazz, Response<T> response, InputStream responseBodyStream)
            throws UnsupportedEncodingException, IOException {

        if (responseBodyStream == null || clazz == Void.class) {
            return;
        } else if (clazz == InputStream.class) {
            response.setBody(responseBodyStream);
            return;
        }

        byte[] responseBody = WebbUtils.readBytes(responseBodyStream);
        // we are ignoring headers describing the content type of the response, instead
        // try to force the content based on the type the client is expecting it (clazz)
        if (clazz == String.class) {
            response.setBody(new String(responseBody, Const.UTF8));
        } else if (clazz == Const.BYTE_ARRAY_CLASS) {
            response.setBody(responseBody);
        } else if (clazz == JSONObject.class) {
            response.setBody(WebbUtils.toJsonObject(responseBody));
        } else if (clazz == JSONArray.class) {
            response.setBody(WebbUtils.toJsonArray(responseBody));
        }
    }

    static <T> void parseErrorResponse(Class<T> clazz, Response<T> response, InputStream responseBodyStream)
            throws UnsupportedEncodingException, IOException {

        if (responseBodyStream == null) {
            return;
        } else if (clazz == InputStream.class) {
            response.errorBody = responseBodyStream;
            return;
        }

        byte[] responseBody = WebbUtils.readBytes(responseBodyStream);
        String contentType = response.connection.getContentType();
        if (contentType == null || contentType.startsWith(Const.APP_BINARY) || clazz == Const.BYTE_ARRAY_CLASS) {
            response.errorBody = responseBody;
            return;
        }

        if (contentType.startsWith(Const.APP_JSON) && clazz == JSONObject.class) {
            try {
                response.errorBody = WebbUtils.toJsonObject(responseBody);
                return;
            } catch (Exception ignored) {
                // ignored - was just a try!
            }
        }

        // fallback to String if bytes are valid UTF-8 characters ...
        try {
            response.errorBody = new String(responseBody, Const.UTF8);
            return;
        } catch (Exception ignored) {
            // ignored - was just a try!
        }

        // last fallback - return error object as byte[]
        response.errorBody = responseBody;
    }
}