diff --git a/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java b/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java new file mode 100644 index 00000000..bb39f423 --- /dev/null +++ b/acme4j-client/src/main/java/org/shredzone/acme4j/util/TimestampParser.java @@ -0,0 +1,86 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses a timestamp as defined in RFC 3339. + * + * @see RFC 3339 + * @author Richard "Shred" Körber + */ +public class TimestampParser { + + private static final Pattern DATE_PATTERN = Pattern.compile( + "^(\\d{4})-(\\d{2})-(\\d{2})T" + + "(\\d{2}):(\\d{2}):(\\d{2})" + + "(?:\\.(\\d{1,3})\\d*)?" + + "(Z|[+-]\\d{2}:?\\d{2})$", Pattern.CASE_INSENSITIVE); + + private static final Pattern TZ_PATTERN = Pattern.compile( + "([+-])(\\d{2}):?(\\d{2})$"); + + /** + * Parses a RFC 3339 formatted date. + * + * @param str + * Date string + * @return {@link Date} that was parsed + * @throws IllegalArgumentException + * if the date string was not RFC 3339 formatted + */ + public static Date parse(String str) { + Matcher m = DATE_PATTERN.matcher(str); + if (!m.matches()) { + throw new IllegalArgumentException("Illegal date: " + str); + } + + int year = Integer.parseInt(m.group(1)); + int month = Integer.parseInt(m.group(2)); + int dom = Integer.parseInt(m.group(3)); + int hour = Integer.parseInt(m.group(4)); + int minute = Integer.parseInt(m.group(5)); + int second = Integer.parseInt(m.group(6)); + + String msStr = m.group(7); + if (msStr == null) { + msStr = "000"; + } else { + while (msStr.length() < 3) { + msStr += '0'; + } + } + int ms = Integer.parseInt(msStr); + + String tz = m.group(8); + if ("Z".equalsIgnoreCase(tz)) { + tz = "GMT"; + } else { + tz = TZ_PATTERN.matcher(tz).replaceAll("GMT$1$2:$3"); + } + + Calendar cal = GregorianCalendar.getInstance(TimeZone.getTimeZone(tz)); + cal.clear(); + cal.set(year, month - 1, dom, hour, minute, second); + cal.set(Calendar.MILLISECOND, ms); + return cal.getTime(); + } + +} diff --git a/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java new file mode 100644 index 00000000..f5247544 --- /dev/null +++ b/acme4j-client/src/test/java/org/shredzone/acme4j/util/TimestampParserTest.java @@ -0,0 +1,163 @@ +/* + * acme4j - Java ACME client + * + * Copyright (C) 2015 Richard "Shred" Körber + * http://acme4j.shredzone.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + */ +package org.shredzone.acme4j.util; + +import static org.junit.Assert.*; +import static org.shredzone.acme4j.util.TimestampParser.parse; + +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; + +import org.hamcrest.BaseMatcher; +import org.hamcrest.Description; +import org.junit.Test; + +/** + * Unit tests for {@link TimestampParser}. + * + * @author Richard "Shred" Körber + */ +public class TimestampParserTest { + + /** + * Test valid strings. + */ + @Test + public void testParser() { + assertThat(parse("2015-12-27T22:58:35.006769519Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.00676951Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.0067695Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.006769Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.00676Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.0067Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.006Z"), isDate(2015, 12, 27, 22, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.01Z"), isDate(2015, 12, 27, 22, 58, 35, 10)); + assertThat(parse("2015-12-27T22:58:35.2Z"), isDate(2015, 12, 27, 22, 58, 35, 200)); + assertThat(parse("2015-12-27T22:58:35Z"), isDate(2015, 12, 27, 22, 58, 35)); + assertThat(parse("2015-12-27t22:58:35z"), isDate(2015, 12, 27, 22, 58, 35)); + + assertThat(parse("2015-12-27T22:58:35.006769519+02:00"), isDate(2015, 12, 27, 20, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.006+02:00"), isDate(2015, 12, 27, 20, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35+02:00"), isDate(2015, 12, 27, 20, 58, 35)); + + assertThat(parse("2015-12-27T21:58:35.006769519-02:00"), isDate(2015, 12, 27, 23, 58, 35, 6)); + assertThat(parse("2015-12-27T21:58:35.006-02:00"), isDate(2015, 12, 27, 23, 58, 35, 6)); + assertThat(parse("2015-12-27T21:58:35-02:00"), isDate(2015, 12, 27, 23, 58, 35)); + + assertThat(parse("2015-12-27T22:58:35.006769519+0200"), isDate(2015, 12, 27, 20, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35.006+0200"), isDate(2015, 12, 27, 20, 58, 35, 6)); + assertThat(parse("2015-12-27T22:58:35+0200"), isDate(2015, 12, 27, 20, 58, 35)); + + assertThat(parse("2015-12-27T21:58:35.006769519-0200"), isDate(2015, 12, 27, 23, 58, 35, 6)); + assertThat(parse("2015-12-27T21:58:35.006-0200"), isDate(2015, 12, 27, 23, 58, 35, 6)); + assertThat(parse("2015-12-27T21:58:35-0200"), isDate(2015, 12, 27, 23, 58, 35)); + } + + /** + * Test invalid strings. + */ + @Test + public void testInvalid() { + try { + parse(""); + fail("accepted empty string"); + } catch (IllegalArgumentException ex) { + // expected + } + + try { + parse("abc"); + fail("accepted nonsense string"); + } catch (IllegalArgumentException ex) { + // expected + } + + try { + parse("2015-12-27"); + fail("accepted year only string"); + } catch (IllegalArgumentException ex) { + // expected + } + + try { + parse("2015-12-27T"); + fail("accepted year only string"); + } catch (IllegalArgumentException ex) { + // expected + } + } + + /** + * Matches the given time. + */ + private DateMatcher isDate(int year, int month, int dom, int hour, int minute, int second) { + return isDate(year, month, dom, hour, minute, second, 0); + } + + /** + * Matches the given time and milliseconds. + */ + private DateMatcher isDate(int year, int month, int dom, int hour, int minute, int second, int ms) { + Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")); + cal.clear(); + cal.set(year, month - 1, dom, hour, minute, second); + cal.set(Calendar.MILLISECOND, ms); + return new DateMatcher(cal); + } + + /** + * Date matcher that gives a readable output on mismatch. + */ + private static class DateMatcher extends BaseMatcher { + + private final Calendar cal; + private final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH); + + public DateMatcher(Calendar cal) { + this.cal = cal; + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + @Override + public boolean matches(Object item) { + if (!(item instanceof Date)) { + return false; + } + + Date date = (Date) item; + return date.equals(cal.getTime()); + } + + @Override + public void describeTo(Description description) { + description.appendValue(sdf.format(cal.getTime())); + } + + @Override + public void describeMismatch(Object item, Description description) { + if (!(item instanceof Date)) { + description.appendText("is not a Date"); + return; + } + + Date date = (Date) item; + description.appendText("was ").appendValue(sdf.format(date)); + } + + } + +}