001/*
002 * Copyright 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.soklet.converter;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.ThreadSafe;
023import java.lang.reflect.Type;
024import java.math.BigDecimal;
025import java.math.BigInteger;
026import java.time.Instant;
027import java.time.LocalDate;
028import java.time.LocalDateTime;
029import java.time.LocalTime;
030import java.time.ZoneId;
031import java.time.format.DateTimeParseException;
032import java.util.Collections;
033import java.util.Currency;
034import java.util.Date;
035import java.util.HashSet;
036import java.util.Locale;
037import java.util.Optional;
038import java.util.Set;
039import java.util.TimeZone;
040import java.util.UUID;
041
042import static com.soklet.Utilities.trimAggressivelyToNull;
043import static java.lang.String.format;
044
045/**
046 * Non-instantiable utility class that exists to vend a set of default {@link ValueConverter} instances via {@link #defaultValueConverters()}.
047 * <p>
048 * Default converters are documented at <a href="https://www.soklet.com/docs/value-conversions#default-conversions">https://www.soklet.com/docs/value-conversions#default-conversions</a>.
049 *
050 * @author <a href="https://www.revetkn.com">Mark Allen</a>
051 */
052@ThreadSafe
053public final class ValueConverters {
054        @NonNull
055        private static final Set<@NonNull ValueConverter<?, ?>> DEFAULT_VALUE_CONVERTERS;
056
057        static {
058                DEFAULT_VALUE_CONVERTERS = Collections.unmodifiableSet(createDefaultValueConverters());
059        }
060
061        private ValueConverters() {
062                // Cannot instantiate
063        }
064
065        /**
066         * Vends the immutable system-default set of {@link ValueConverter} instances.
067         * <p>
068         * The returned set is guaranteed to be a JVM-wide singleton.
069         *
070         * @return the default set of converters
071         */
072        @NonNull
073        public static Set<@NonNull ValueConverter<?, ?>> defaultValueConverters() {
074                return DEFAULT_VALUE_CONVERTERS;
075        }
076
077        @NonNull
078        private static Set<@NonNull ValueConverter<?, ?>> createDefaultValueConverters() {
079                Set<@NonNull ValueConverter<?, ?>> defaultValueConverters = new HashSet<>();
080
081                // Primitives
082                defaultValueConverters.add(new StringToIntegerValueConverter());
083                defaultValueConverters.add(new StringToLongValueConverter());
084                defaultValueConverters.add(new StringToDoubleValueConverter());
085                defaultValueConverters.add(new StringToFloatValueConverter());
086                defaultValueConverters.add(new StringToByteValueConverter());
087                defaultValueConverters.add(new StringToShortValueConverter());
088                defaultValueConverters.add(new StringToCharacterValueConverter());
089                defaultValueConverters.add(new StringToBooleanValueConverter());
090                defaultValueConverters.add(new StringToBigIntegerValueConverter());
091                defaultValueConverters.add(new StringToBigDecimalValueConverter());
092                defaultValueConverters.add(new StringToNumberValueConverter());
093                defaultValueConverters.add(new StringToUuidValueConverter());
094                defaultValueConverters.add(new StringToInstantValueConverter());
095                defaultValueConverters.add(new StringToDateValueConverter());
096                defaultValueConverters.add(new StringToLocalDateValueConverter());
097                defaultValueConverters.add(new StringToLocalTimeValueConverter());
098                defaultValueConverters.add(new StringToLocalDateTimeValueConverter());
099                defaultValueConverters.add(new StringToZoneIdValueConverter());
100                defaultValueConverters.add(new StringToTimeZoneValueConverter());
101                defaultValueConverters.add(new StringToLocaleValueConverter());
102                defaultValueConverters.add(new StringToCurrencyValueConverter());
103
104                return defaultValueConverters;
105        }
106
107        // Primitives
108
109        @ThreadSafe
110        private static final class StringToIntegerValueConverter extends FromStringValueConverter<Integer> {
111                @Override
112                @NonNull
113                public Optional<Integer> performConversion(@Nullable String from) throws Exception {
114                        if (from == null)
115                                return Optional.empty();
116
117                        return Optional.of(Integer.parseInt(from));
118                }
119        }
120
121        @ThreadSafe
122        private static final class StringToLongValueConverter extends FromStringValueConverter<Long> {
123                @Override
124                @NonNull
125                public Optional<Long> performConversion(@Nullable String from) throws Exception {
126                        if (from == null)
127                                return Optional.empty();
128
129                        return Optional.of(Long.parseLong(from));
130                }
131        }
132
133        @ThreadSafe
134        private static final class StringToDoubleValueConverter extends FromStringValueConverter<Double> {
135                @Override
136                @NonNull
137                public Optional<Double> performConversion(@Nullable String from) throws Exception {
138                        if (from == null)
139                                return Optional.empty();
140
141                        return Optional.of(Double.parseDouble(from));
142                }
143        }
144
145        @ThreadSafe
146        private static final class StringToFloatValueConverter extends FromStringValueConverter<Float> {
147                @Override
148                @NonNull
149                public Optional<Float> performConversion(@Nullable String from) throws Exception {
150                        if (from == null)
151                                return Optional.empty();
152
153                        return Optional.of(Float.parseFloat(from));
154                }
155        }
156
157        @ThreadSafe
158        private static final class StringToByteValueConverter extends FromStringValueConverter<Byte> {
159                @Override
160                @NonNull
161                public Optional<Byte> performConversion(@Nullable String from) throws Exception {
162                        if (from == null)
163                                return Optional.empty();
164
165                        return Optional.of(Byte.parseByte(from));
166                }
167        }
168
169        @ThreadSafe
170        private static final class StringToShortValueConverter extends FromStringValueConverter<Short> {
171                @Override
172                @NonNull
173                public Optional<Short> performConversion(@Nullable String from) throws Exception {
174                        if (from == null)
175                                return Optional.empty();
176
177                        return Optional.of(Short.parseShort(from));
178                }
179        }
180
181        @ThreadSafe
182        private static final class StringToCharacterValueConverter extends FromStringValueConverter<Character> {
183                @NonNull
184                @Override
185                public Optional<Character> performConversion(@Nullable String from) throws Exception {
186                        if (from == null)
187                                return Optional.empty();
188
189                        String trimmedFrom = trimAggressivelyToNull(from);
190
191                        // Special handling for all-whitespace.
192                        // If there is at least one space, return ' '
193                        if (from.length() > 0 && trimmedFrom == null)
194                                return Optional.of(' ');
195
196                        if (trimmedFrom.length() != 1)
197                                throw new ValueConversionException(format(
198                                                "Unable to convert %s value '%s' to %s. Reason: '%s' is not a single-character String.", getFromType(), trimmedFrom,
199                                                getToType(), from), getFromType(), from, getToType());
200
201                        return Optional.of(trimmedFrom.charAt(0));
202                }
203
204                @NonNull
205                @Override
206                protected Boolean shouldTrimFromValues() {
207                        // Special handling: we want to handle trimming ourselves
208                        return false;
209                }
210
211                @NonNull
212                @Override
213                public Type getFromType() {
214                        return String.class;
215                }
216
217                @NonNull
218                @Override
219                public Type getToType() {
220                        return Character.class;
221                }
222        }
223
224        @ThreadSafe
225        private static final class StringToBooleanValueConverter extends FromStringValueConverter<Boolean> {
226                @Override
227                @NonNull
228                public Optional<Boolean> performConversion(@Nullable String from) throws Exception {
229                        if (from == null)
230                                return Optional.empty();
231
232                        boolean isTrue = "true".equalsIgnoreCase(from);
233
234                        if (isTrue)
235                                return Optional.of(true);
236
237                        boolean isFalse = "false".equalsIgnoreCase(from);
238
239                        if (isFalse)
240                                return Optional.of(false);
241
242                        throw new IllegalArgumentException(format("'%s' is not a valid boolean value", from));
243                }
244        }
245
246        // Nonprimitives
247
248        @ThreadSafe
249        private static final class StringToBigIntegerValueConverter extends FromStringValueConverter<BigInteger> {
250                @Override
251                @NonNull
252                public Optional<BigInteger> performConversion(@Nullable String from) throws Exception {
253                        if (from == null)
254                                return Optional.empty();
255
256                        return Optional.of(new BigInteger(from));
257                }
258        }
259
260        @ThreadSafe
261        private static final class StringToBigDecimalValueConverter extends FromStringValueConverter<BigDecimal> {
262                @Override
263                @NonNull
264                public Optional<BigDecimal> performConversion(@Nullable String from) throws Exception {
265                        if (from == null)
266                                return Optional.empty();
267
268                        return Optional.of(new BigDecimal(from));
269                }
270        }
271
272        @ThreadSafe
273        private static final class StringToNumberValueConverter extends FromStringValueConverter<Number> {
274                @Override
275                @NonNull
276                public Optional<Number> performConversion(@Nullable String from) throws Exception {
277                        if (from == null)
278                                return Optional.empty();
279
280                        return Optional.of(new BigDecimal(from));
281                }
282        }
283
284        @ThreadSafe
285        private static final class StringToUuidValueConverter extends FromStringValueConverter<UUID> {
286                @Override
287                @NonNull
288                public Optional<UUID> performConversion(@Nullable String from) throws Exception {
289                        if (from == null)
290                                return Optional.empty();
291
292                        return Optional.of(UUID.fromString(from));
293                }
294        }
295
296        @ThreadSafe
297        private static final class StringToDateValueConverter extends FromStringValueConverter<Date> {
298                @Override
299                @NonNull
300                public Optional<Date> performConversion(@Nullable String from) throws Exception {
301                        if (from == null)
302                                return Optional.empty();
303
304                        try {
305                                return Optional.of(Date.from(Instant.parse(from)));
306                        } catch (DateTimeParseException ignored) {
307                                return Optional.of(new Date(Long.parseLong(from)));
308                        }
309                }
310        }
311
312        @ThreadSafe
313        private static final class StringToInstantValueConverter extends FromStringValueConverter<Instant> {
314                @Override
315                @NonNull
316                public Optional<Instant> performConversion(@Nullable String from) throws Exception {
317                        if (from == null)
318                                return Optional.empty();
319
320                        try {
321                                return Optional.of(Instant.parse(from));
322                        } catch (Exception ignored) {
323                                return Optional.of(Instant.ofEpochMilli(Long.parseLong(from)));
324                        }
325                }
326        }
327
328        @ThreadSafe
329        private static final class StringToLocalDateValueConverter extends FromStringValueConverter<LocalDate> {
330                @Override
331                @NonNull
332                public Optional<LocalDate> performConversion(@Nullable String from) throws Exception {
333                        if (from == null)
334                                return Optional.empty();
335
336                        return Optional.of(LocalDate.parse(from));
337                }
338        }
339
340        @ThreadSafe
341        private static final class StringToLocalTimeValueConverter extends FromStringValueConverter<LocalTime> {
342                @Override
343                @NonNull
344                public Optional<LocalTime> performConversion(@Nullable String from) throws Exception {
345                        if (from == null)
346                                return Optional.empty();
347
348                        return Optional.of(LocalTime.parse(from));
349                }
350        }
351
352        @ThreadSafe
353        private static final class StringToLocalDateTimeValueConverter extends FromStringValueConverter<LocalDateTime> {
354                @Override
355                @NonNull
356                public Optional<LocalDateTime> performConversion(@Nullable String from) throws Exception {
357                        if (from == null)
358                                return Optional.empty();
359
360                        return Optional.of(LocalDateTime.parse(from));
361                }
362        }
363
364        @ThreadSafe
365        private static final class StringToZoneIdValueConverter extends FromStringValueConverter<ZoneId> {
366                @Override
367                @NonNull
368                public Optional<ZoneId> performConversion(@Nullable String from) throws Exception {
369                        if (from == null)
370                                return Optional.empty();
371
372                        return Optional.of(ZoneId.of(from));
373                }
374        }
375
376        @ThreadSafe
377        private static final class StringToTimeZoneValueConverter extends FromStringValueConverter<TimeZone> {
378                @Override
379                @NonNull
380                public Optional<TimeZone> performConversion(@Nullable String from) throws Exception {
381                        if (from == null)
382                                return Optional.empty();
383
384                        // Use ZoneId.of since it will throw an exception if the format is invalid.
385                        // TimeZone.getTimeZone() returns GMT for invalid formats, which is not the behavior we want
386                        return Optional.of(TimeZone.getTimeZone(ZoneId.of(from)));
387                }
388        }
389
390        @ThreadSafe
391        private static final class StringToLocaleValueConverter extends FromStringValueConverter<Locale> {
392                @Override
393                @NonNull
394                public Optional<Locale> performConversion(@Nullable String from) throws Exception {
395                        if (from == null)
396                                return Optional.empty();
397
398                        return Optional.of(new Locale.Builder().setLanguageTag(from).build());
399                }
400        }
401
402        @ThreadSafe
403        private static final class StringToCurrencyValueConverter extends FromStringValueConverter<Currency> {
404                @Override
405                @NonNull
406                public Optional<Currency> performConversion(@Nullable String from) throws Exception {
407                        if (from == null)
408                                return Optional.empty();
409
410                        return Optional.of(Currency.getInstance(from));
411                }
412        }
413}