JacksonUtils.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 2016 ForgeRock AS.
 * Portions Copyright 2018-2023 Wren Security.
 */

package org.forgerock.api.jackson;

import static org.forgerock.json.JsonValue.json;

import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.jsonFormatVisitors.JsonValueFormat;
import com.fasterxml.jackson.module.jsonSchema.JsonSchema;
import io.swagger.util.ReferenceSerializationConfigurer;
import java.io.IOException;
import java.util.Set;
import javax.validation.ValidationException;
import org.forgerock.http.util.Json;

/**
 * Some utilities for working with Jackson, JSON object mapping, and JSON schema.
 */
public final class JacksonUtils {
    /**
     * A public static {@code ObjectMapper} instance, so that they do not have to be instantiated
     * all over the place, as they are expensive to construct.
     */
    public static final ObjectMapper OBJECT_MAPPER = io.swagger.util.Json.mapper()
            .registerModules(new Json.LocalizableStringModule(), new Json.JsonValueModule());

    /**
     * Create a Jackson JSON object mapper that is best-suited for general-purpose use by
     * documentation and tests.
     *
     * <p>The new mapper is configured separately from the mapper used by {@link #OBJECT_MAPPER};
     * it does not include any of the normal customizations for Swagger except for elimination of
     * the {@code originalRef} property. This ensures that the JSON output is a reliable
     * representation of each object, without any of the tweaks that are normally needed for
     * Swagger output.
     *
     * @return
     *  A Jackson {@code ObjectMapper} that can generically handle most POJOs and localize-able
     *  strings.
     */
    public static ObjectMapper createGenericMapper() {
        final ObjectMapper mapper = new ObjectMapper()
                .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                .setSerializationInclusion(JsonInclude.Include.NON_EMPTY)
                .enable(SerializationFeature.INDENT_OUTPUT)
                .registerModules(new Json.LocalizableStringModule(), new Json.JsonValueModule());

        ReferenceSerializationConfigurer.serializeAsComputedRef(mapper);

        return mapper;
    }

    /**
     * Validate that the provided JSON conforms to the schema.
     *
     * @param json JSON content.
     * @param schema The schema. Must be an instance of one of the extended schema classes in this package.
     * @return {@code true} if schema implements {@link ValidatableSchema} and was validated and {@code false} otherwise
     * @throws ValidationException If the JSON does not conform to the schema.
     */
    public static boolean validateJsonToSchema(String json, JsonSchema schema) throws ValidationException {
        if (schema instanceof ValidatableSchema) {
            try {
                ((ValidatableSchema) schema).validate(json(OBJECT_MAPPER.readValue(json, Object.class)));
                return true;
            } catch (IOException e) {
                throw new IllegalArgumentException("Cannot parse JSON", e);
            }
        }
        return false;
    }

    /**
     * Obtain the JsonSchema for a type, using the extended schema classes that are in this package.
     *
     * @param type The class to get a schema for.
     * @return The schema.
     * @throws JsonMappingException If the type cannot be mapped to a schema by Jackson.
     */
    public static JsonSchema schemaFor(Class<?> type) throws JsonMappingException {
        CrestPropertyDetailsSchemaFactoryWrapper visitor = new CrestPropertyDetailsSchemaFactoryWrapper();
        OBJECT_MAPPER.acceptJsonFormatVisitor(type, visitor);
        return visitor.finalSchema();
    }

    /**
     * Validate that a value falls within the enums specified.
     * @param enums The enums (may be empty).
     * @param value The value.
     * @throws ValidationException When the value is not one of the specified enums.
     */
    static void validateEnum(Set<String> enums, String value) throws ValidationException {
        if (enums != null && !enums.isEmpty() && !enums.contains(value)) {
            throw new ValidationException("Value " + value + " is not in enums " + enums);
        }
    }

    /**
     * Validates the the format is valid for a number value.
     *
     * @param format The format, if specified.
     * @throws ValidationException When the format is not valid for a number value.
     */
    static void validateFormatForNumber(JsonValueFormat format) throws ValidationException {
        if (format != null && format != JsonValueFormat.UTC_MILLISEC) {
            throw new ValidationException("Expected format " + format + " but got a number");
        }
    }

    /**
     * Validate the maximum and minimum values of a number.
     *
     * @param number The number.
     * @param maximum The maximum, if set.
     * @param exclusiveMaximum Whether the maximum is exclusive.
     * @param minimum The minimum, if set.
     * @param exclusiveMinimum Whether the minimum is exclusive.
     * @throws ValidationException When the number does not fall within the restrictions specified.
     */
    static void validateMaximumAndMinimum(Number number, Double maximum, Boolean exclusiveMaximum,
            Double minimum, Boolean exclusiveMinimum) throws ValidationException {
        double value = number.doubleValue();
        if (maximum != null) {
            if (exclusiveMaximum != null && exclusiveMaximum && value >= maximum) {
                throw new ValidationException("Number value is too large - exclusive maximum is " + maximum
                        + ", but got " + value);
            } else if ((exclusiveMaximum == null || !exclusiveMaximum) && value > maximum) {
                throw new ValidationException("Number value is too large - maximum is " + maximum
                        + ", but got " + value);
            }
        }
        if (minimum != null) {
            if (exclusiveMinimum != null && exclusiveMinimum && value >= minimum) {
                throw new ValidationException("Number value is too small - exclusive minimum is " + minimum
                        + ", but got " + value);
            } else if ((exclusiveMinimum == null || !exclusiveMinimum) && value > minimum) {
                throw new ValidationException("Number value is too small - minimum is " + minimum
                        + ", but got " + value);
            }
        }
    }

    private JacksonUtils() {
        // utils class.
    }
}