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;
018
019import org.jspecify.annotations.NonNull;
020import org.jspecify.annotations.Nullable;
021
022import javax.annotation.concurrent.ThreadSafe;
023import java.time.DateTimeException;
024import java.time.Instant;
025import java.time.ZoneId;
026import java.time.ZoneOffset;
027import java.time.format.DateTimeFormatter;
028import java.time.format.DateTimeFormatterBuilder;
029import java.time.format.SignStyle;
030import java.time.temporal.ChronoField;
031import java.util.List;
032import java.util.Locale;
033import java.util.Optional;
034import java.util.concurrent.atomic.AtomicReference;
035
036import static java.util.Objects.requireNonNull;
037
038/**
039 * Formatter and parser for HTTP-date-valued headers.
040 *
041 * @author <a href="https://www.revetkn.com">Mark Allen</a>
042 */
043@ThreadSafe
044public final class HttpDate {
045        @NonNull
046        private static final ZoneId GMT;
047        private static final int MINIMUM_IMF_FIXDATE_YEAR;
048        private static final int MAXIMUM_IMF_FIXDATE_YEAR;
049        @NonNull
050        private static final DateTimeFormatter IMF_FIXDATE_FORMATTER;
051        @NonNull
052        private static final DateTimeFormatter RFC_1123_PARSER;
053        @NonNull
054        private static final DateTimeFormatter RFC_1036_PARSER;
055        @NonNull
056        private static final DateTimeFormatter TWO_DIGIT_YEAR_LEGACY_PARSER;
057        @NonNull
058        private static final DateTimeFormatter ASCTIME_PARSER;
059        @NonNull
060        private static final List<@NonNull DateTimeFormatter> PARSERS;
061        @NonNull
062        private static final AtomicReference<@NonNull CachedValue> CURRENT_SECOND_HEADER_VALUE;
063
064        static {
065                GMT = ZoneId.of("GMT");
066                MINIMUM_IMF_FIXDATE_YEAR = 1;
067                MAXIMUM_IMF_FIXDATE_YEAR = 9_999;
068                IMF_FIXDATE_FORMATTER = new DateTimeFormatterBuilder()
069                                .parseCaseInsensitive()
070                                .appendPattern("EEE, dd MMM ")
071                                .appendValue(ChronoField.YEAR, 4)
072                                .appendPattern(" HH:mm:ss 'GMT'")
073                                .toFormatter(Locale.US)
074                                .withZone(GMT);
075                RFC_1123_PARSER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(GMT);
076                RFC_1036_PARSER = new DateTimeFormatterBuilder()
077                                .parseCaseInsensitive()
078                                .appendPattern("EEEE, dd-MMM-")
079                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900)
080                                .appendPattern(" HH:mm:ss zzz")
081                                .toFormatter(Locale.US)
082                                .withZone(ZoneOffset.UTC);
083                TWO_DIGIT_YEAR_LEGACY_PARSER = new DateTimeFormatterBuilder()
084                                .parseCaseInsensitive()
085                                .appendPattern("EEE, dd MMM ")
086                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900)
087                                .appendPattern(" HH:mm:ss zzz")
088                                .toFormatter(Locale.US)
089                                .withZone(ZoneOffset.UTC);
090                ASCTIME_PARSER = new DateTimeFormatterBuilder()
091                                .parseCaseInsensitive()
092                                .appendPattern("EEE MMM")
093                                .appendLiteral(' ')
094                                .optionalStart().appendLiteral(' ').optionalEnd()
095                                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
096                                .appendPattern(" HH:mm:ss yyyy")
097                                .toFormatter(Locale.US)
098                                .withZone(ZoneOffset.UTC);
099                PARSERS = List.of(RFC_1123_PARSER, RFC_1036_PARSER, TWO_DIGIT_YEAR_LEGACY_PARSER, ASCTIME_PARSER);
100                CURRENT_SECOND_HEADER_VALUE = new AtomicReference<>(CachedValue.fromInstant(Instant.now()));
101        }
102
103        private HttpDate() {
104                // Non-instantiable
105        }
106
107        /**
108         * Formats the provided instant as an IMF-fixdate HTTP header value.
109         *
110         * @param instant the instant to format
111         * @return the HTTP-date header value
112         * @throws IllegalArgumentException if the instant cannot be represented with a four-digit IMF-fixdate year
113         */
114        @NonNull
115        public static String toHeaderValue(@NonNull Instant instant) {
116                requireNonNull(instant);
117                validateImfFixdateYear(instant);
118                return IMF_FIXDATE_FORMATTER.format(instant);
119        }
120
121        @NonNull
122        public static Optional<Instant> fromHeaderValue(@Nullable String headerValue) {
123                String trimmed = Utilities.trimAggressivelyToNull(headerValue);
124
125                if (trimmed == null)
126                        return Optional.empty();
127
128                for (DateTimeFormatter parser : PARSERS) {
129                        try {
130                                return Optional.of(Instant.from(parser.parse(trimmed)));
131                        } catch (Exception ignored) {
132                                // Try the next HTTP-date format.
133                        }
134                }
135
136                return Optional.empty();
137        }
138
139        @NonNull
140        public static String currentSecondHeaderValue() {
141                Long currentEpochSecond = Instant.now().getEpochSecond();
142                CachedValue cachedValue = requireNonNull(CURRENT_SECOND_HEADER_VALUE.get());
143
144                if (cachedValue.epochSecond().equals(currentEpochSecond))
145                        return cachedValue.headerValue();
146
147                CachedValue newValue = CachedValue.fromEpochSecond(currentEpochSecond);
148
149                if (CURRENT_SECOND_HEADER_VALUE.compareAndSet(cachedValue, newValue))
150                        return newValue.headerValue();
151
152                return requireNonNull(CURRENT_SECOND_HEADER_VALUE.get()).headerValue();
153        }
154
155        private static void validateImfFixdateYear(@NonNull Instant instant) {
156                int year;
157
158                try {
159                        year = instant.atZone(GMT).get(ChronoField.YEAR);
160                } catch (DateTimeException e) {
161                        throw new IllegalArgumentException("Instant is outside the supported IMF-fixdate year range.", e);
162                }
163
164                if (year < MINIMUM_IMF_FIXDATE_YEAR || year > MAXIMUM_IMF_FIXDATE_YEAR)
165                        throw new IllegalArgumentException("IMF-fixdate year must be between 0001 and 9999.");
166        }
167
168        private record CachedValue(@NonNull Long epochSecond,
169                                                                                                                 @NonNull String headerValue) {
170                @NonNull
171                static CachedValue fromInstant(@NonNull Instant instant) {
172                        requireNonNull(instant);
173                        return fromEpochSecond(instant.getEpochSecond());
174                }
175
176                @NonNull
177                static CachedValue fromEpochSecond(@NonNull Long epochSecond) {
178                        requireNonNull(epochSecond);
179                        Instant instant = Instant.ofEpochSecond(epochSecond);
180                        return new CachedValue(epochSecond, toHeaderValue(instant));
181                }
182        }
183}