Json.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2010–2011 ApexIdentity Inc.
 * Portions Copyright 2011-2016 ForgeRock AS.
 */

package org.forgerock.http.util;

import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_COMMENTS;
import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_SINGLE_QUOTES;
import static com.fasterxml.jackson.core.JsonParser.Feature.ALLOW_UNQUOTED_CONTROL_CHARS;
import static java.lang.String.format;

import java.io.IOException;
import java.io.InputStream;
import java.io.Reader;
import java.io.StringReader;
import java.util.Arrays;
import java.util.List;
import java.util.Map;

import org.forgerock.http.header.AcceptLanguageHeader;
import org.forgerock.http.header.MalformedHeaderException;
import org.forgerock.http.protocol.Request;
import org.forgerock.util.i18n.LocalizableString;
import org.forgerock.util.i18n.PreferredLocales;

import com.fasterxml.jackson.annotation.JsonValue;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;


/**
 * Provides read and write JSON capabilities.
 * Can check if an object reference is JSON-compatible (expressed as primitive values, list/array and map).
 */
public final class Json {

    /** Non strict object mapper / data binder used to read json configuration files/data. */
    private static final ObjectMapper LENIENT_MAPPER;
    static {
        LENIENT_MAPPER = new ObjectMapper().registerModules(new JsonValueModule(), new LocalizableStringModule());
        LENIENT_MAPPER.configure(ALLOW_COMMENTS, true);
        LENIENT_MAPPER.configure(ALLOW_SINGLE_QUOTES, true);
        LENIENT_MAPPER.configure(ALLOW_UNQUOTED_CONTROL_CHARS, true);
    }

    /** Strict object mapper / data binder used to read json configuration files/data. */
    private static final ObjectMapper STRICT_MAPPER = new ObjectMapper()
            .registerModules(new JsonValueModule(), new LocalizableStringModule());

    /**
     * Attribute Key for the {@link org.forgerock.util.i18n.PreferredLocales} instance.
     */
    private static final String PREFERRED_LOCALES_ATTRIBUTE = "PreferredLocales";

    /**
     * Jackson Module that adds a serializer for {@link LocalizableString}.
     */
    public static class LocalizableStringModule extends SimpleModule {

        private static final PreferredLocales DEFAULT_PREFERRED_LOCALES = new PreferredLocales();

        /** Default constructor. */
        public LocalizableStringModule() {
            addSerializer(LocalizableString.class, new JsonSerializer<LocalizableString>() {
                @Override
                public void serialize(LocalizableString localizableString, JsonGenerator jsonGenerator,
                        SerializerProvider serializerProvider) throws IOException {
                    PreferredLocales locales =
                            (PreferredLocales) serializerProvider.getAttribute(PREFERRED_LOCALES_ATTRIBUTE);
                    if (locales == null) {
                        locales = DEFAULT_PREFERRED_LOCALES;
                    }
                    jsonGenerator.writeString(localizableString.toTranslatedString(locales));
                }
            });
        }
    }

    /**
     * Jackson Module that uses a mixin to make sure that a {@link org.forgerock.json.JsonValue} instance is
     * serialized using its {@code #getObject()} value only.
      */
    public static class JsonValueModule extends SimpleModule {
        @Override
        public void setupModule(SetupContext context) {
            context.setMixInAnnotations(org.forgerock.json.JsonValue.class, JsonValueMixin.class);
        }
    }

    private static abstract class JsonValueMixin {
        @JsonValue
        public abstract String getObject();
    }

        /**
     * Private constructor for utility class.
     */
    private Json() { }

    /**
     * Verify that the given parameter object is of a JSON compatible type (recursively). If no exception is thrown that
     * means the parameter can be used in the JWT session (that is a JSON value).
     *
     * @param trail
     *         pointer to the verified object
     * @param value
     *         object to verify
     */
    public static void checkJsonCompatibility(final String trail, final Object value) {

        // Null is OK
        if (value == null) {
            return;
        }

        Class<?> type = value.getClass();
        Object object = value;

        // JSON supports Boolean
        if (object instanceof Boolean) {
            return;
        }

        // JSON supports Chars (as String)
        if (object instanceof Character) {
            return;
        }

        // JSON supports Numbers (Long, Float, ...)
        if (object instanceof Number) {
            return;
        }

        // JSON supports String
        if (object instanceof CharSequence) {
            return;
        }

        // Consider array like a List
        if (type.isArray()) {
            object = Arrays.asList((Object[]) value);
        }

        if (object instanceof List) {
            List<?> list = (List<?>) object;
            for (int i = 0; i < list.size(); i++) {
                checkJsonCompatibility(format("%s[%d]", trail, i), list.get(i));
            }
            return;
        }

        if (object instanceof Map) {
            Map<?, ?> map = (Map<?, ?>) object;
            for (Map.Entry<?, ?> entry : map.entrySet()) {
                checkJsonCompatibility(format("%s/%s", trail, entry.getKey()), entry.getValue());
            }
            return;
        }

        throw new IllegalArgumentException(format(
                "The object referenced through '%s' cannot be safely serialized as JSON",
                trail));
    }

    /**
     * Parses to json the provided data.
     *
     * @param rawData
     *            The data as a string to read and parse.
     * @see Json#readJson(Reader)
     * @return Any of {@code Map<String, Object>}, {@code List<Object>}, {@code Number}, {@code Boolean}
     *         or {@code null}.
     * @throws IOException
     *             If an exception occurs during parsing the data.
     */
    public static Object readJson(final String rawData) throws IOException {
        if (rawData == null) {
            return null;
        }
        return readJson(new StringReader(rawData));
    }

    /**
     * Parses to json the provided reader.
     *
     * @param reader
     *            The data to parse.
     * @return Any of {@code Map<String, Object>}, {@code List<Object>}, {@code Number}, {@code Boolean}
     *         or {@code null}.
     * @throws IOException
     *             If an exception occurs during parsing the data.
     */
    public static Object readJson(final Reader reader) throws IOException {
        return parse(STRICT_MAPPER, reader);
    }

    /**
     * This function it's only used to read our configuration files and allows
     * JSON files to contain non strict JSON such as comments or single quotes.
     *
     * @param reader
     *            The stream of data to parse.
     * @return Any of {@code Map<String, Object>}, {@code List<Object>}, {@code Number}, {@code Boolean}
     *         or {@code null}.
     * @throws IOException
     *             If an error occurs during reading/parsing the data.
     */
    public static Object readJsonLenient(final Reader reader) throws IOException {
        return parse(LENIENT_MAPPER, reader);
    }

    /**
     * This function it's only used to read our configuration files and allows
     * JSON files to contain non strict JSON such as comments or single quotes.
     *
     * @param in
     *            The input stream containing the json.
     * @return Any of {@code Map<String, Object>}, {@code List<Object>}, {@code Number}, {@code Boolean}
     *         or {@code null}.
     * @throws IOException
     *             If an error occurs during reading/parsing the data.
     */
    public static Object readJsonLenient(final InputStream in) throws IOException {
        if (in == null) {
            return null;
        }

        return LENIENT_MAPPER.readValue(in, Object.class);
    }

    private static Object parse(ObjectMapper mapper, Reader reader) throws IOException {
        if (reader == null) {
            return null;
        }

        return mapper.readValue(reader, Object.class);
    }

    /**
     * Writes the JSON content of the object passed in parameter.
     *
     * @param objectToWrite
     *            The object we want to serialize as JSON output. The
     * @return the Json output as a byte array.
     * @throws IOException
     *             If an error occurs during writing/mapping content.
     */
    public static byte[] writeJson(final Object objectToWrite) throws IOException {
        return STRICT_MAPPER.writeValueAsBytes(objectToWrite);
    }

    /**
     * Make an object writer that contains the locales from the request for serialization of {@link LocalizableString}
     * instances. The provided {@code mapper} will be used to create the writer so that serialization configuration is
     * not recreated.
     *
     * @param mapper The {@code ObjectMapper} to obtain a writer from.
     * @param request The CHF request.
     * @return The configured {@code ObjectWriter}.
     * @throws MalformedHeaderException If the Accept-Language header is malformed.
     */
    public static ObjectWriter makeLocalizingObjectWriter(ObjectMapper mapper, Request request)
            throws MalformedHeaderException {
        return makeLocalizingObjectWriter(mapper,
                request.getHeaders().containsKey(AcceptLanguageHeader.NAME)
                        ? request.getHeaders().get(AcceptLanguageHeader.class).getLocales()
                        : null);
    }

    /**
     * Make an object writer that contains the provided locales for serialization of {@link LocalizableString}
     * instances. The provided {@code mapper} will be used to create the writer so that serialization configuration is
     * not recreated.
     *
     * @param mapper The {@code ObjectMapper} to obtain a writer from.
     * @param locales The {@code PreferredLocales} instance to use for localization, or {@code null}.
     * @return The configured {@code ObjectWriter}.
     */
    public static ObjectWriter makeLocalizingObjectWriter(ObjectMapper mapper, PreferredLocales locales) {
        ObjectWriter writer = mapper.writer();
        if (locales != null) {
            writer = writer.withAttribute(Json.PREFERRED_LOCALES_ATTRIBUTE, locales);
        }
        return writer;
    }

}