Skip to content

Instantly share code, notes, and snippets.

@svenjacobs
Last active August 24, 2022 06:07
Show Gist options
  • Save svenjacobs/ced311bd27ee0cbb135a595fd03e5a1f to your computer and use it in GitHub Desktop.
Save svenjacobs/ced311bd27ee0cbb135a595fd03e5a1f to your computer and use it in GitHub Desktop.
RFC 822 date/time parser for kotlinx-datetime
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)
}
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