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.Instant;
024import java.time.ZoneId;
025import java.time.ZoneOffset;
026import java.time.format.DateTimeFormatter;
027import java.time.format.DateTimeFormatterBuilder;
028import java.time.format.SignStyle;
029import java.time.temporal.ChronoField;
030import java.util.List;
031import java.util.Locale;
032import java.util.Optional;
033import java.util.concurrent.atomic.AtomicReference;
034
035import static java.util.Objects.requireNonNull;
036
037/**
038 * Formatter and parser for HTTP-date-valued headers.
039 *
040 * @author <a href="https://www.revetkn.com">Mark Allen</a>
041 */
042@ThreadSafe
043public final class HttpDate {
044        @NonNull
045        private static final ZoneId GMT;
046        @NonNull
047        private static final DateTimeFormatter IMF_FIXDATE_FORMATTER;
048        @NonNull
049        private static final DateTimeFormatter RFC_1123_PARSER;
050        @NonNull
051        private static final DateTimeFormatter RFC_1036_PARSER;
052        @NonNull
053        private static final DateTimeFormatter TWO_DIGIT_YEAR_LEGACY_PARSER;
054        @NonNull
055        private static final DateTimeFormatter ASCTIME_PARSER;
056        @NonNull
057        private static final List<@NonNull DateTimeFormatter> PARSERS;
058        @NonNull
059        private static final AtomicReference<CachedValue> CURRENT_SECOND_HEADER_VALUE;
060
061        static {
062                GMT = ZoneId.of("GMT");
063                IMF_FIXDATE_FORMATTER = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
064                                .withZone(GMT);
065                RFC_1123_PARSER = DateTimeFormatter.RFC_1123_DATE_TIME.withZone(GMT);
066                RFC_1036_PARSER = new DateTimeFormatterBuilder()
067                                .parseCaseInsensitive()
068                                .appendPattern("EEEE, dd-MMM-")
069                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900)
070                                .appendPattern(" HH:mm:ss zzz")
071                                .toFormatter(Locale.US)
072                                .withZone(ZoneOffset.UTC);
073                TWO_DIGIT_YEAR_LEGACY_PARSER = new DateTimeFormatterBuilder()
074                                .parseCaseInsensitive()
075                                .appendPattern("EEE, dd MMM ")
076                                .appendValueReduced(ChronoField.YEAR, 2, 2, 1900)
077                                .appendPattern(" HH:mm:ss zzz")
078                                .toFormatter(Locale.US)
079                                .withZone(ZoneOffset.UTC);
080                ASCTIME_PARSER = new DateTimeFormatterBuilder()
081                                .parseCaseInsensitive()
082                                .appendPattern("EEE MMM")
083                                .appendLiteral(' ')
084                                .optionalStart().appendLiteral(' ').optionalEnd()
085                                .appendValue(ChronoField.DAY_OF_MONTH, 1, 2, SignStyle.NOT_NEGATIVE)
086                                .appendPattern(" HH:mm:ss yyyy")
087                                .toFormatter(Locale.US)
088                                .withZone(ZoneOffset.UTC);
089                PARSERS = List.of(RFC_1123_PARSER, RFC_1036_PARSER, TWO_DIGIT_YEAR_LEGACY_PARSER, ASCTIME_PARSER);
090                CURRENT_SECOND_HEADER_VALUE = new AtomicReference<>(CachedValue.fromInstant(Instant.now()));
091        }
092
093        private HttpDate() {
094                // Non-instantiable
095        }
096
097        @NonNull
098        public static String toHeaderValue(@NonNull Instant instant) {
099                requireNonNull(instant);
100                return IMF_FIXDATE_FORMATTER.format(instant);
101        }
102
103        @NonNull
104        public static Optional<Instant> fromHeaderValue(@Nullable String headerValue) {
105                String trimmed = Utilities.trimAggressivelyToNull(headerValue);
106
107                if (trimmed == null)
108                        return Optional.empty();
109
110                for (DateTimeFormatter parser : PARSERS) {
111                        try {
112                                return Optional.of(Instant.from(parser.parse(trimmed)));
113                        } catch (Exception ignored) {
114                                // Try the next HTTP-date format.
115                        }
116                }
117
118                return Optional.empty();
119        }
120
121        @NonNull
122        public static String currentSecondHeaderValue() {
123                Long currentEpochSecond = Instant.now().getEpochSecond();
124                CachedValue cachedValue = CURRENT_SECOND_HEADER_VALUE.get();
125
126                if (cachedValue.epochSecond().equals(currentEpochSecond))
127                        return cachedValue.headerValue();
128
129                CachedValue newValue = CachedValue.fromEpochSecond(currentEpochSecond);
130
131                if (CURRENT_SECOND_HEADER_VALUE.compareAndSet(cachedValue, newValue))
132                        return newValue.headerValue();
133
134                return CURRENT_SECOND_HEADER_VALUE.get().headerValue();
135        }
136
137        private record CachedValue(@NonNull Long epochSecond,
138                                                                                                                 @NonNull String headerValue) {
139                @NonNull
140                static CachedValue fromInstant(@NonNull Instant instant) {
141                        requireNonNull(instant);
142                        return fromEpochSecond(instant.getEpochSecond());
143                }
144
145                @NonNull
146                static CachedValue fromEpochSecond(@NonNull Long epochSecond) {
147                        requireNonNull(epochSecond);
148                        Instant instant = Instant.ofEpochSecond(epochSecond);
149                        return new CachedValue(epochSecond, toHeaderValue(instant));
150                }
151        }
152}