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}