//// Format and parse HTTP-date timestamps (RFC 9110, section 5.6.7), the
//// format used by HTTP headers such as `Date`, `Last-Modified`, and
//// `Expires`.
import gleam/int
import gleam/list
import gleam/result
import gleam/string
import gleam/time/calendar
import gleam/time/timestamp.{type Timestamp}
/// Render a `Timestamp` as an RFC 9110 IMF-fixdate string.
///
/// ```gleam
/// to_string(timestamp.from_unix_seconds(1_782_697_748))
/// // -> "Mon, 29 Jun 2026 01:49:08 GMT"
/// ```
pub fn to_string(timestamp: Timestamp) -> String {
let #(date, time) = timestamp.to_calendar(timestamp, calendar.utc_offset)
let calendar.Date(year:, month:, day:) = date
let calendar.TimeOfDay(hours:, minutes:, seconds:, ..) = time
weekday_name(day_of_week(year, month, day))
<> ", "
<> pad2(day)
<> " "
<> month_name(month)
<> " "
<> pad4(year)
<> " "
<> pad2(hours)
<> ":"
<> pad2(minutes)
<> ":"
<> pad2(seconds)
<> " GMT"
}
fn pad2(n: Int) -> String {
string.pad_start(int.to_string(n), to: 2, with: "0")
}
fn pad4(n: Int) -> String {
string.pad_start(int.to_string(n), to: 4, with: "0")
}
fn month_name(month: calendar.Month) -> String {
case month {
calendar.January -> "Jan"
calendar.February -> "Feb"
calendar.March -> "Mar"
calendar.April -> "Apr"
calendar.May -> "May"
calendar.June -> "Jun"
calendar.July -> "Jul"
calendar.August -> "Aug"
calendar.September -> "Sep"
calendar.October -> "Oct"
calendar.November -> "Nov"
calendar.December -> "Dec"
}
}
fn weekday_name(dow: Int) -> String {
case dow {
0 -> "Sun"
1 -> "Mon"
2 -> "Tue"
3 -> "Wed"
4 -> "Thu"
5 -> "Fri"
_ -> "Sat"
}
}
// Sakamoto's algorithm. Returns 0 = Sunday .. 6 = Saturday
fn day_of_week(year: Int, month: calendar.Month, day: Int) -> Int {
let y = case month {
calendar.January | calendar.February -> year - 1
_ -> year
}
let t = case month {
calendar.January -> 0
calendar.February -> 3
calendar.March -> 2
calendar.April -> 5
calendar.May -> 0
calendar.June -> 3
calendar.July -> 5
calendar.August -> 1
calendar.September -> 4
calendar.October -> 6
calendar.November -> 2
calendar.December -> 4
}
{ y + y / 4 - y / 100 + y / 400 + t + day } % 7
}
/// Parse an HTTP-date into a `Timestamp`. Accepts all three formats in
/// RFC 9110: IMF-fixdate, the obsolete RFC 850 (2-digit year), and
/// asctime. Returns `Error(Nil)` on malformed or out-of-range input.
///
/// ```gleam
/// parse("Mon, 29 Jun 2026 01:49:08 GMT")
/// // -> Ok(timestamp.from_unix_seconds(1_782_697_748))
/// ```
pub fn parse(input: String) -> Result(Timestamp, Nil) {
let tokens =
input
|> string.split(" ")
|> list.filter(fn(token) { token != "" })
case tokens {
// IMF-fixdate: "Mon, 29 Jun 2026 01:49:08 GMT"
[_dow, day, month, year, time, "GMT"] ->
parse_fields(day:, month:, year:, time:)
// RFC 850: "Monday, 29-Jun-26 01:49:08 GMT"
[_dow, date, time, "GMT"] -> parse_rfc850(date, time)
// asctime: "Mon Jun 29 01:49:08 2026"
[_day, month, day, time, year] -> parse_fields(day:, month:, year:, time:)
_ -> Error(Nil)
}
}
fn parse_fields(
day day: String,
month month: String,
year year: String,
time time: String,
) -> Result(Timestamp, Nil) {
use year <- result.try(int.parse(year))
build_timestamp(day:, month:, year:, time:)
}
fn build_timestamp(
day day: String,
month month: String,
year year: Int,
time time: String,
) -> Result(Timestamp, Nil) {
use day <- result.try(int.parse(day))
use month <- result.try(month_from_name(month))
use #(hours, minutes, seconds) <- result.try(parse_time(time))
let date = calendar.Date(year:, month:, day:)
case calendar.is_valid_date(date) && valid_time(hours, minutes, seconds) {
True -> {
let time_of_day =
calendar.TimeOfDay(hours:, minutes:, seconds:, nanoseconds: 0)
Ok(timestamp.from_calendar(date, time_of_day, calendar.utc_offset))
}
False -> Error(Nil)
}
}
fn parse_rfc850(date: String, time: String) -> Result(Timestamp, Nil) {
case string.split(date, "-") {
[day, month, two_digit_year] -> {
use year <- result.try(expand_two_digit_year(two_digit_year))
build_timestamp(day:, month:, year:, time:)
}
_ -> Error(Nil)
}
}
fn expand_two_digit_year(two_digit_year: String) -> Result(Int, Nil) {
case int.parse(two_digit_year) {
Ok(year) if year >= 0 && year <= 69 -> Ok(2000 + year)
Ok(year) if year >= 70 && year <= 99 -> Ok(1900 + year)
_ -> Error(Nil)
}
}
fn month_from_name(name: String) -> Result(calendar.Month, Nil) {
case name {
"Jan" -> Ok(calendar.January)
"Feb" -> Ok(calendar.February)
"Mar" -> Ok(calendar.March)
"Apr" -> Ok(calendar.April)
"May" -> Ok(calendar.May)
"Jun" -> Ok(calendar.June)
"Jul" -> Ok(calendar.July)
"Aug" -> Ok(calendar.August)
"Sep" -> Ok(calendar.September)
"Oct" -> Ok(calendar.October)
"Nov" -> Ok(calendar.November)
"Dec" -> Ok(calendar.December)
_ -> Error(Nil)
}
}
fn parse_time(time: String) -> Result(#(Int, Int, Int), Nil) {
case string.split(time, ":") {
[hours, minutes, seconds] -> {
use hours <- result.try(int.parse(hours))
use minutes <- result.try(int.parse(minutes))
use seconds <- result.try(int.parse(seconds))
Ok(#(hours, minutes, seconds))
}
_ -> Error(Nil)
}
}
// Accepts the RFC 9110 leap-second value 60
fn valid_time(hours: Int, minutes: Int, seconds: Int) -> Bool {
hours >= 0
&& hours <= 23
&& minutes >= 0
&& minutes <= 59
&& seconds >= 0
&& seconds <= 60
}