Last active
August 24, 2022 06:07
-
-
Save svenjacobs/ced311bd27ee0cbb135a595fd03e5a1f to your computer and use it in GitHub Desktop.
RFC 822 date/time parser for kotlinx-datetime
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import kotlinx.datetime.* | |
/** | |
* RFC 822 date/time format to [Instant] parser. | |
* | |
* See https://www.rfc-editor.org/rfc/rfc822#section-5 | |
*/ | |
class Rfc822InstantParser { | |
private enum class Month { | |
Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec, | |
} | |
/** | |
* Parse RFC 822 date/time format to [Instant] | |
* | |
* @throws IllegalArgumentException in case input string cannot be parsed as RFC 822 | |
*/ | |
fun parse(input: String): Instant { | |
/** | |
* Groups: | |
* | |
* 1 = day of month (1-31) | |
* 2 = month (Jan, Feb, Mar, ...) | |
* 3 = year (2022) | |
* 4 = hour (00-23) | |
* 5 = minute (00-59) | |
* 6 = OPTIONAL: second (00-59) | |
* 7 = time zone (+/-hhmm or letters) | |
*/ | |
val regex = | |
Regex("^(?:\\w{3}, )?(\\d{1,2}) (\\w{3}) (\\d{4}) (\\d{2}):(\\d{2})(?::(\\d{2}))? ([+-]?\\w+)\$") | |
val result = regex.matchEntire(input) | |
if (result == null || result.groups.size != 8) { | |
throw IllegalArgumentException("Unexpected RFC 822 date/time format") | |
} | |
try { | |
val dayOfMonth = result.groupValues[1].toInt() | |
val month = Month.valueOf(result.groupValues[2]) | |
val year = result.groupValues[3].toInt() | |
val hour = result.groupValues[4].toInt() | |
val minute = result.groupValues[5].toInt() | |
val second = result.groupValues[6].ifEmpty { "00" }.toInt() | |
val timeZone = result.groupValues[7] | |
val dateTime = LocalDateTime( | |
year = year, | |
monthNumber = month.ordinal + 1, | |
dayOfMonth = dayOfMonth, | |
hour = hour, | |
minute = minute, | |
second = second, | |
nanosecond = 0, | |
) | |
val tz = parseTimeZone(timeZone) | |
return dateTime.toInstant(tz) | |
} catch (e: Exception) { | |
throw IllegalArgumentException("Unexpected RFC 822 date/time format", e) | |
} | |
} | |
/** | |
* @see parse | |
*/ | |
operator fun invoke(input: String): Instant = parse(input) | |
private fun parseTimeZone(timeZone: String): TimeZone { | |
val startsWithPlus = timeZone.startsWith('+') | |
val startsWithMinus = timeZone.startsWith('-') | |
val (hours, minutes) = when { | |
startsWithPlus || startsWithMinus -> { | |
val hour = timeZone.substring(1..2).toInt() | |
val minute = timeZone.substring(3..4).toInt() | |
if (startsWithMinus) { | |
Pair(-hour, -minute) | |
} else { | |
Pair(hour, minute) | |
} | |
} | |
// Time zones | |
else -> when (timeZone) { | |
"Z", "UT", "GMT" -> 0.hours | |
"EST" -> (-5).hours | |
"EDT" -> (-4).hours | |
"CST" -> (-6).hours | |
"CDT" -> (-5).hours | |
"MST" -> (-7).hours | |
"MDT" -> (-6).hours | |
"PST" -> (-8).hours | |
"PDT" -> (-7).hours | |
// Military | |
"A" -> (-1).hours | |
"M" -> (-12).hours | |
"N" -> 1.hours | |
"Y" -> 12.hours | |
else -> throw IllegalArgumentException("Unexpected time zone format") | |
} | |
} | |
val offset = UtcOffset(hours = hours, minutes = minutes, seconds = 0) | |
return offset.asTimeZone() | |
} | |
private val Int.hours | |
get() = Pair(this, 0) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import io.kotest.assertions.throwables.shouldThrow | |
import io.kotest.core.spec.style.WordSpec | |
import io.kotest.matchers.shouldBe | |
import kotlinx.datetime.Instant | |
import kotlinx.datetime.Month | |
import kotlinx.datetime.TimeZone | |
import kotlinx.datetime.toLocalDateTime | |
class Rfc822InstantParserTest : WordSpec( | |
{ | |
val parser = Rfc822InstantParser() | |
"parse" should { | |
"parse date with positive time zone offset" { | |
val instant = parser("Tue, 23 Aug 2022 06:30:00 +0200") | |
instant.shouldBe( | |
dayOfMonth = 23, | |
month = Month.AUGUST, | |
year = 2022, | |
hour = 4, | |
minute = 30, | |
second = 0, | |
) | |
} | |
"parse date with negative time zone offset" { | |
val instant = parser("Tue, 12 Jul 2022 10:00:00 -0330") | |
instant.shouldBe( | |
dayOfMonth = 12, | |
month = Month.JULY, | |
year = 2022, | |
hour = 13, | |
minute = 30, | |
second = 0, | |
) | |
} | |
"parse date with UT time zone offset" { | |
val instant = parser("Sat, 1 Jan 2022 10:59:59 UT") | |
instant.shouldBe( | |
dayOfMonth = 1, | |
month = Month.JANUARY, | |
year = 2022, | |
hour = 10, | |
minute = 59, | |
second = 59, | |
) | |
} | |
"parse date without optional day name" { | |
val instant = parser("24 Dec 2022 20:00:00 Z") | |
instant.shouldBe( | |
dayOfMonth = 24, | |
month = Month.DECEMBER, | |
year = 2022, | |
hour = 20, | |
minute = 0, | |
second = 0, | |
) | |
} | |
"parse date without optional seconds" { | |
val instant = parser("31 Dec 2022 23:59 Z") | |
instant.shouldBe( | |
dayOfMonth = 31, | |
month = Month.DECEMBER, | |
year = 2022, | |
hour = 23, | |
minute = 59, | |
second = 0, | |
) | |
} | |
"throw exception for unknown format" { | |
shouldThrow<IllegalArgumentException> { | |
parser("2022-08-23T07:01:35Z") | |
} | |
} | |
} | |
} | |
) | |
private fun Instant.shouldBe( | |
dayOfMonth: Int, | |
month: Month, | |
year: Int, | |
hour: Int, | |
minute: Int, | |
second: Int, | |
timeZone: TimeZone = TimeZone.UTC, | |
) { | |
val dateTime = toLocalDateTime(timeZone) | |
dateTime.dayOfMonth shouldBe dayOfMonth | |
dateTime.month shouldBe month | |
dateTime.year shouldBe year | |
dateTime.hour shouldBe hour | |
dateTime.minute shouldBe minute | |
dateTime.second shouldBe second | |
dateTime.nanosecond shouldBe 0 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment