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}