001/* 002 * Copyright 2017-2022 Product Mog LLC, 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.lokalized; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import java.util.ArrayList; 024import java.util.Collections; 025import java.util.HashMap; 026import java.util.List; 027import java.util.LinkedHashSet; 028import java.util.Locale; 029import java.util.Map; 030import java.util.Optional; 031 032import static com.lokalized.Diagnostics.format; 033import static java.util.Objects.requireNonNull; 034 035/** 036 * Exception thrown when no translation is found and the configured {@link TranslationFailureHandler} chooses to throw. 037 * <p> 038 * This class is intended for use by a single thread. 039 * <p> 040 * Java serialization is supported when every non-null placeholder value and its reachable object graph implement 041 * {@link java.io.Serializable}. Otherwise, serialization fails with {@link java.io.NotSerializableException}. 042 * The supported serialized form begins with 3.0.0 and is not compatible with streams written by 2.x releases. 043 * 044 * @author <a href="https://revetkn.com">Mark Allen</a> 045 */ 046@NotThreadSafe 047public class MissingTranslationException extends RuntimeException { 048 private static final long serialVersionUID = 1L; 049 050 /** Translation key that could not be resolved. */ 051 @NonNull 052 private final String key; 053 /** Locale used to begin per-key locale fallback. */ 054 @NonNull 055 private final Locale lookupLocale; 056 /** Locale-negotiation diagnostics, when available. */ 057 @Nullable 058 private final LocaleMatchResult localeMatchResult; 059 /** Caller-supplied placeholders for the failed translation attempt. */ 060 @NonNull 061 private final Map<@NonNull String, @Nullable Object> placeholders; 062 /** Final reason that the translation attempt failed. */ 063 @NonNull 064 private final TranslationFailureReason reason; 065 /** Ordered locales attempted before the failure. */ 066 @NonNull 067 private final List<@NonNull Locale> attemptedLocales; 068 069 /** 070 * Constructs a new missing-translation exception. 071 * 072 * @param message failure message, not null 073 * @param key translation key, not null 074 * @param placeholders caller placeholders, not null 075 * @param lookupLocale the locale used to begin locale fallback, not null 076 * @throws IllegalArgumentException if the lookup locale is malformed or the placeholders contain a null key 077 */ 078 public MissingTranslationException(@NonNull String message, 079 @NonNull String key, 080 @NonNull Map<@NonNull String, @Nullable Object> placeholders, 081 @NonNull Locale lookupLocale) { 082 this(message, key, placeholders, lookupLocale, null, TranslationFailureReason.MISSING_TRANSLATION, 083 Collections.singletonList(lookupLocale)); 084 } 085 086 /** 087 * Constructs an exception with the complete failed-lookup outcome. 088 * 089 * @param message failure message, not null 090 * @param key translation key, not null 091 * @param placeholders caller placeholders, not null 092 * @param lookupLocale locale used to begin locale fallback, not null 093 * @param reason final failure reason, not null 094 * @param attemptedLocales ordered locales attempted, not null 095 * @throws IllegalArgumentException if the placeholders contain a null key, any locale is malformed, attempted 096 * locales have duplicate language tags, or the reason is {@code RESOLUTION_FAILURE} 097 * @since 3.0.0 098 */ 099 public MissingTranslationException(@NonNull String message, 100 @NonNull String key, 101 @NonNull Map<@NonNull String, @Nullable Object> placeholders, 102 @NonNull Locale lookupLocale, 103 @NonNull TranslationFailureReason reason, 104 @NonNull List<@NonNull Locale> attemptedLocales) { 105 this(message, key, placeholders, lookupLocale, null, reason, attemptedLocales); 106 } 107 108 /** 109 * Constructs an exception with complete failed-lookup and locale-negotiation diagnostics. 110 * 111 * @param message failure message, not null 112 * @param key translation key, not null 113 * @param placeholders caller placeholders, not null 114 * @param lookupLocale locale used to begin locale fallback, not null 115 * @param localeMatchResult strict locale-negotiation diagnostics, or null when unavailable 116 * @param reason final failure reason, not null 117 * @param attemptedLocales ordered locales attempted, not null 118 * @throws IllegalArgumentException if the placeholders contain a null key, any locale is malformed, attempted 119 * locales have duplicate language tags, or the reason is {@code RESOLUTION_FAILURE} 120 * @since 3.0.0 121 */ 122 public MissingTranslationException(@NonNull String message, 123 @NonNull String key, 124 @NonNull Map<@NonNull String, @Nullable Object> placeholders, 125 @NonNull Locale lookupLocale, 126 @Nullable LocaleMatchResult localeMatchResult, 127 @NonNull TranslationFailureReason reason, 128 @NonNull List<@NonNull Locale> attemptedLocales) { 129 super(requireNonNull(message)); 130 131 requireNonNull(key); 132 requireNonNull(placeholders); 133 requireNonNull(reason); 134 requireNonNull(attemptedLocales); 135 136 if (reason == TranslationFailureReason.RESOLUTION_FAILURE) 137 throw new IllegalArgumentException("MissingTranslationException cannot represent a resolution failure cause"); 138 139 Map<@NonNull String, @Nullable Object> placeholderCopy = new HashMap<>(placeholders); 140 141 if (placeholderCopy.containsKey(null)) 142 throw new IllegalArgumentException("Placeholder names must not be null"); 143 144 this.key = key; 145 this.placeholders = Collections.unmodifiableMap(placeholderCopy); 146 this.lookupLocale = LocaleUtils.requireWellFormed(lookupLocale, "Lookup locale"); 147 this.localeMatchResult = localeMatchResult; 148 this.reason = reason; 149 List<@NonNull Locale> attemptedLocaleCopy = new ArrayList<>(attemptedLocales.size()); 150 LinkedHashSet<@NonNull String> attemptedLanguageTags = new LinkedHashSet<>(); 151 152 for (Locale attemptedLocale : attemptedLocales) { 153 Locale validatedLocale = LocaleUtils.requireWellFormed(attemptedLocale, "Attempted locale"); 154 String normalizedLanguageTag = validatedLocale.toLanguageTag().toLowerCase(Locale.ROOT); 155 156 if (!attemptedLanguageTags.add(normalizedLanguageTag)) 157 throw new IllegalArgumentException(format( 158 "Attempted locales must not contain duplicate language tag '%s'", validatedLocale.toLanguageTag())); 159 160 attemptedLocaleCopy.add(validatedLocale); 161 } 162 163 if (new LinkedHashSet<>(attemptedLocaleCopy).size() != attemptedLocaleCopy.size()) 164 throw new IllegalArgumentException("Attempted locales must not contain duplicates"); 165 166 this.attemptedLocales = Collections.unmodifiableList(attemptedLocaleCopy); 167 } 168 169 /** 170 * The translation key that triggered this exception. 171 * 172 * @return the translation key, not null 173 */ 174 @NonNull 175 public String getKey() { 176 return this.key; 177 } 178 179 /** 180 * The placeholders specified for the failed translation attempt. 181 * 182 * @return the placeholders, not null 183 */ 184 @NonNull 185 public Map<@NonNull String, @Nullable Object> getPlaceholders() { 186 return this.placeholders; 187 } 188 189 /** 190 * The locale used to begin per-key locale fallback. 191 * 192 * @return the locale, not null 193 * @since 3.0.0 194 */ 195 @NonNull 196 public Locale getLookupLocale() { 197 return this.lookupLocale; 198 } 199 200 /** 201 * Gets strict locale-negotiation diagnostics when available. 202 * 203 * @return strict locale-negotiation diagnostics when available, otherwise empty, not null 204 * @since 3.0.0 205 */ 206 @NonNull 207 public Optional<@NonNull LocaleMatchResult> getLocaleMatchResult() { 208 return Optional.ofNullable(localeMatchResult); 209 } 210 211 /** 212 * Gets the final failure reason. 213 * 214 * @return final failure reason, not null 215 * @since 3.0.0 216 */ 217 @NonNull 218 public TranslationFailureReason getReason() { 219 return reason; 220 } 221 222 /** 223 * Gets the ordered locales attempted before failure. 224 * 225 * @return ordered locales attempted before failure, not null 226 * @since 3.0.0 227 */ 228 @NonNull 229 public List<@NonNull Locale> getAttemptedLocales() { 230 return attemptedLocales; 231 } 232}