//// 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 }