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}