001/*
002 * SPDX-License-Identifier: Apache-2.0
003 *
004 * Copyright 2025-2026 The Enola <https://enola.dev> Authors
005 *
006 * Licensed under the Apache License, Version 2.0 (the "License");
007 * you may not use this file except in compliance with the License.
008 * You may obtain a copy of the License at
009 *
010 *     https://www.apache.org/licenses/LICENSE-2.0
011 *
012 * Unless required by applicable law or agreed to in writing, software
013 * distributed under the License is distributed on an "AS IS" BASIS,
014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
015 * See the License for the specific language governing permissions and
016 * limitations under the License.
017 */
018package dev.enola.common.jackson;
019
020import com.fasterxml.jackson.annotation.JsonInclude;
021import com.fasterxml.jackson.annotation.JsonSetter;
022import com.fasterxml.jackson.annotation.Nulls;
023import com.fasterxml.jackson.core.json.JsonReadFeature;
024import com.fasterxml.jackson.databind.ObjectMapper;
025import com.fasterxml.jackson.databind.cfg.CoercionAction;
026import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
027import com.fasterxml.jackson.databind.module.SimpleModule;
028import com.fasterxml.jackson.databind.type.LogicalType;
029import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
030import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator;
031import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
032import com.fasterxml.jackson.datatype.guava.GuavaModule;
033import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
034import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
035
036import org.yaml.snakeyaml.DumperOptions;
037import org.yaml.snakeyaml.LoaderOptions;
038
039import java.util.List;
040import java.util.Locale;
041import java.util.Map;
042import java.util.Set;
043
044public final class ObjectMappers {
045
046    /**
047     * A shared, thread-safe, "immutable" default JSON {@link ObjectMapper} instance.
048     *
049     * <p>This instance MUST NOT be reconfigured (e.g. by calling {@link
050     * ObjectMapper#configure(com.fasterxml.jackson.databind.DeserializationFeature, boolean)} or
051     * similar methods) because it is shared.
052     *
053     * <p>If you need a specific configuration, use {@link #newJsonObjectMapper()} to obtain a
054     * separate new instance, configure it, and then keep it for re-use.
055     */
056    public static final ObjectMapper JSON = newJsonObjectMapper();
057
058    /**
059     * Creates a new JSON {@link ObjectMapper} pre-configured with Enola defaults.
060     *
061     * <p>The returned instance is a new separate object which can be safely re-configured (e.g. to
062     * set {@link com.fasterxml.jackson.databind.DeserializationFeature#FAIL_ON_UNKNOWN_PROPERTIES}
063     * to false).
064     *
065     * <p>It is recommended to keep and re-use the obtained instance for performance reasons.
066     */
067    public static ObjectMapper newJsonObjectMapper() {
068        var objectMapper = new ObjectMapper();
069        configure(objectMapper);
070        return objectMapper;
071    }
072
073    public static final ObjectMapper YAML = newYamlObjectMapper();
074
075    private static ObjectMapper newYamlObjectMapper() {
076        // NB: Keep in-sync with the similar (but not the same, different API!) in
077        //       the dev.enola.common.yamljson.YAML class.
078        var loaderOptions = new LoaderOptions();
079        loaderOptions.setAllowDuplicateKeys(false);
080        loaderOptions.setAllowRecursiveKeys(false);
081        loaderOptions.setCodePointLimit(10 * 1024 * 1024); // 10 MB
082
083        var dumperOptions = new DumperOptions();
084        dumperOptions.setExplicitStart(false);
085        dumperOptions.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN);
086
087        var yamlFactory =
088                YAMLFactory.builder()
089                        .loaderOptions(loaderOptions)
090                        .dumperOptions(dumperOptions)
091                        .build();
092
093        var yamlMapper = new YAMLMapper(yamlFactory);
094        yamlMapper.disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER);
095        yamlMapper.enable(YAMLGenerator.Feature.MINIMIZE_QUOTES);
096
097        configure(yamlMapper);
098        return yamlMapper;
099    }
100
101    /** Configure the given ObjectMapper with Enola-specific settings. */
102    public static void configure(ObjectMapper mapper) {
103        // Do NOT use mapper.findAndRegisterModules();
104        // because that would mean that mapping would depend on (uncontrollable) classpath
105
106        // https://github.com/FasterXML/jackson-modules-java8
107        mapper.registerModule(new JavaTimeModule());
108        mapper.registerModule(new Jdk8Module());
109        mapper.registerModule(new GuavaModule());
110
111        SimpleModule module = new SimpleModule();
112        module.addSerializer(Locale.class, new LocaleSerializer());
113        module.addDeserializer(Locale.class, new LocaleDeserializer());
114        mapper.registerModule(module);
115
116        // Enable JSONc features: Java-style comments and trailing commas
117        // https://github.com/enola-dev/enola/issues/1847
118        mapper.getFactory().enable(JsonReadFeature.ALLOW_JAVA_COMMENTS.mappedFeature());
119        mapper.getFactory().enable(JsonReadFeature.ALLOW_TRAILING_COMMA.mappedFeature());
120
121        // DO fail on unknown properties - this helps to spot errors in configuration files etc.
122        // NO! mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
123
124        // Always skip empty sequences ([]) and maps
125        mapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
126
127        // Always set missing sequences to empty collections
128        var nullAsEmpty = JsonSetter.Value.forValueNulls(Nulls.AS_EMPTY);
129        mapper.configOverride(List.class).setSetterInfo(nullAsEmpty);
130        mapper.configOverride(Set.class).setSetterInfo(nullAsEmpty);
131        mapper.configOverride(Map.class).setSetterInfo(nullAsEmpty);
132
133        // Always allow coercion for e.g. empty Map keys etc.
134        mapper.coercionConfigFor(LogicalType.POJO)
135                .setAcceptBlankAsEmpty(true)
136                .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
137        mapper.coercionConfigFor(LogicalType.Array)
138                .setAcceptBlankAsEmpty(true)
139                .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
140        mapper.coercionConfigFor(LogicalType.Map)
141                .setAcceptBlankAsEmpty(true)
142                .setCoercion(CoercionInputShape.EmptyString, CoercionAction.AsNull);
143    }
144
145    private ObjectMappers() {}
146}