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}