1 package com.fasterxml.jackson.databind.util; 2 3 import java.text.ParseException; 4 import java.text.ParsePosition; 5 import java.util.*; 6 7 /** 8 * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC friendly than using SimpleDateFormat so 9 * highly suitable if you (un)serialize lots of date objects. 10 * 11 * Supported parse format: [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]] 12 * 13 * @see <a href="http://www.w3.org/TR/NOTE-datetime">this specification</a> 14 */ 15 @Deprecated // since 2.9 16 public class ISO8601Utils 17 { 18 protected final static int DEF_8601_LEN = "yyyy-MM-ddThh:mm:ss.SSS+00:00".length(); 19 20 /** 21 * Timezone we use for 'Z' in ISO-8601 date/time values: since 2.7 22 * {@link #TIMEZONE_UTC}; with earlier versions up to 2.7 was {@link #TIMEZONE_GMT}. 23 */ 24 private static final TimeZone TIMEZONE_Z = TimeZone.getTimeZone("UTC"); 25 26 /* 27 /********************************************************** 28 /* Formatting 29 /********************************************************** 30 */ 31 32 /** 33 * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision) 34 * 35 * @param date the date to format 36 * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ' 37 */ format(Date date)38 public static String format(Date date) { 39 return format(date, false, TIMEZONE_Z); 40 } 41 42 /** 43 * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone) 44 * 45 * @param date the date to format 46 * @param millis true to include millis precision otherwise false 47 * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z' 48 */ format(Date date, boolean millis)49 public static String format(Date date, boolean millis) { 50 return format(date, millis, TIMEZONE_Z); 51 } 52 53 @Deprecated // since 2.9 format(Date date, boolean millis, TimeZone tz)54 public static String format(Date date, boolean millis, TimeZone tz) { 55 return format(date, millis, tz, Locale.US); 56 } 57 58 /** 59 * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] 60 * 61 * @param date the date to format 62 * @param millis true to include millis precision otherwise false 63 * @param tz timezone to use for the formatting (UTC will produce 'Z') 64 * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] 65 * 66 * @since 2.9 67 */ format(Date date, boolean millis, TimeZone tz, Locale loc)68 public static String format(Date date, boolean millis, TimeZone tz, Locale loc) { 69 Calendar calendar = new GregorianCalendar(tz, loc); 70 calendar.setTime(date); 71 72 // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) 73 StringBuilder sb = new StringBuilder(30); 74 sb.append(String.format( 75 "%04d-%02d-%02dT%02d:%02d:%02d", 76 calendar.get(Calendar.YEAR), 77 calendar.get(Calendar.MONTH) + 1, 78 calendar.get(Calendar.DAY_OF_MONTH), 79 calendar.get(Calendar.HOUR_OF_DAY), 80 calendar.get(Calendar.MINUTE), 81 calendar.get(Calendar.SECOND) 82 )); 83 if (millis) { 84 sb.append(String.format(".%03d", calendar.get(Calendar.MILLISECOND))); 85 } 86 87 int offset = tz.getOffset(calendar.getTimeInMillis()); 88 if (offset != 0) { 89 int hours = Math.abs((offset / (60 * 1000)) / 60); 90 int minutes = Math.abs((offset / (60 * 1000)) % 60); 91 sb.append(String.format("%c%02d:%02d", 92 (offset < 0 ? '-' : '+'), 93 hours, minutes)); 94 } else { 95 sb.append('Z'); 96 } 97 return sb.toString(); 98 } 99 100 /* 101 /********************************************************** 102 /* Parsing 103 /********************************************************** 104 */ 105 106 /** 107 * Parse a date from ISO-8601 formatted string. It expects a format 108 * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh:mm]] 109 * 110 * @param date ISO string to parse in the appropriate format. 111 * @param pos The position to start parsing from, updated to where parsing stopped. 112 * @return the parsed date 113 * @throws ParseException if the date is not in the appropriate format 114 */ parse(String date, ParsePosition pos)115 public static Date parse(String date, ParsePosition pos) throws ParseException { 116 Exception fail = null; 117 try { 118 int offset = pos.getIndex(); 119 120 // extract year 121 int year = parseInt(date, offset, offset += 4); 122 if (checkOffset(date, offset, '-')) { 123 offset += 1; 124 } 125 126 // extract month 127 int month = parseInt(date, offset, offset += 2); 128 if (checkOffset(date, offset, '-')) { 129 offset += 1; 130 } 131 132 // extract day 133 int day = parseInt(date, offset, offset += 2); 134 // default time value 135 int hour = 0; 136 int minutes = 0; 137 int seconds = 0; 138 int milliseconds = 0; // always use 0 otherwise returned date will include millis of current time 139 140 // if the value has no time component (and no time zone), we are done 141 boolean hasT = checkOffset(date, offset, 'T'); 142 143 if (!hasT && (date.length() <= offset)) { 144 Calendar calendar = new GregorianCalendar(year, month - 1, day); 145 146 pos.setIndex(offset); 147 return calendar.getTime(); 148 } 149 150 if (hasT) { 151 152 // extract hours, minutes, seconds and milliseconds 153 hour = parseInt(date, offset += 1, offset += 2); 154 if (checkOffset(date, offset, ':')) { 155 offset += 1; 156 } 157 158 minutes = parseInt(date, offset, offset += 2); 159 if (checkOffset(date, offset, ':')) { 160 offset += 1; 161 } 162 // second and milliseconds can be optional 163 if (date.length() > offset) { 164 char c = date.charAt(offset); 165 if (c != 'Z' && c != '+' && c != '-') { 166 seconds = parseInt(date, offset, offset += 2); 167 if (seconds > 59 && seconds < 63) seconds = 59; // truncate up to 3 leap seconds 168 // milliseconds can be optional in the format 169 if (checkOffset(date, offset, '.')) { 170 offset += 1; 171 int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit 172 int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits 173 int fraction = parseInt(date, offset, parseEndOffset); 174 // compensate for "missing" digits 175 switch (parseEndOffset - offset) { // number of digits parsed 176 case 2: 177 milliseconds = fraction * 10; 178 break; 179 case 1: 180 milliseconds = fraction * 100; 181 break; 182 default: 183 milliseconds = fraction; 184 } 185 offset = endOffset; 186 } 187 } 188 } 189 } 190 191 // extract timezone 192 if (date.length() <= offset) { 193 throw new IllegalArgumentException("No time zone indicator"); 194 } 195 196 TimeZone timezone = null; 197 char timezoneIndicator = date.charAt(offset); 198 199 if (timezoneIndicator == 'Z') { 200 timezone = TIMEZONE_Z; 201 offset += 1; 202 } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { 203 String timezoneOffset = date.substring(offset); 204 offset += timezoneOffset.length(); 205 // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" 206 if ("+0000".equals(timezoneOffset) || "+00:00".equals(timezoneOffset)) { 207 timezone = TIMEZONE_Z; 208 } else { 209 // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... 210 // not sure why, but that's the way it looks. Further, Javadocs for 211 // `java.util.TimeZone` specifically instruct use of GMT as base for 212 // custom timezones... odd. 213 String timezoneId = "GMT" + timezoneOffset; 214 // String timezoneId = "UTC" + timezoneOffset; 215 216 timezone = TimeZone.getTimeZone(timezoneId); 217 218 String act = timezone.getID(); 219 if (!act.equals(timezoneId)) { 220 /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given 221 * one without. If so, don't sweat. 222 * Yes, very inefficient. Hopefully not hit often. 223 * If it becomes a perf problem, add 'loose' comparison instead. 224 */ 225 String cleaned = act.replace(":", ""); 226 if (!cleaned.equals(timezoneId)) { 227 throw new IndexOutOfBoundsException("Mismatching time zone indicator: "+timezoneId+" given, resolves to " 228 +timezone.getID()); 229 } 230 } 231 } 232 } else { 233 throw new IndexOutOfBoundsException("Invalid time zone indicator '" + timezoneIndicator+"'"); 234 } 235 236 Calendar calendar = new GregorianCalendar(timezone); 237 calendar.setLenient(false); 238 calendar.set(Calendar.YEAR, year); 239 calendar.set(Calendar.MONTH, month - 1); 240 calendar.set(Calendar.DAY_OF_MONTH, day); 241 calendar.set(Calendar.HOUR_OF_DAY, hour); 242 calendar.set(Calendar.MINUTE, minutes); 243 calendar.set(Calendar.SECOND, seconds); 244 calendar.set(Calendar.MILLISECOND, milliseconds); 245 246 pos.setIndex(offset); 247 return calendar.getTime(); 248 // If we get a ParseException it'll already have the right message/offset. 249 // Other exception types can convert here. 250 } catch (Exception e) { 251 fail = e; 252 } 253 String input = (date == null) ? null : ('"' + date + '"'); 254 String msg = fail.getMessage(); 255 if (msg == null || msg.isEmpty()) { 256 msg = "("+fail.getClass().getName()+")"; 257 } 258 ParseException ex = new ParseException("Failed to parse date " + input + ": " + msg, pos.getIndex()); 259 ex.initCause(fail); 260 throw ex; 261 } 262 263 /** 264 * Check if the expected character exist at the given offset in the value. 265 * 266 * @param value the string to check at the specified offset 267 * @param offset the offset to look for the expected character 268 * @param expected the expected character 269 * @return true if the expected character exist at the given offset 270 */ checkOffset(String value, int offset, char expected)271 private static boolean checkOffset(String value, int offset, char expected) { 272 return (offset < value.length()) && (value.charAt(offset) == expected); 273 } 274 275 /** 276 * Parse an integer located between 2 given offsets in a string 277 * 278 * @param value the string to parse 279 * @param beginIndex the start index for the integer in the string 280 * @param endIndex the end index for the integer in the string 281 * @return the int 282 * @throws NumberFormatException if the value is not a number 283 */ parseInt(String value, int beginIndex, int endIndex)284 private static int parseInt(String value, int beginIndex, int endIndex) throws NumberFormatException { 285 if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) { 286 throw new NumberFormatException(value); 287 } 288 // use same logic as in Integer.parseInt() but less generic we're not supporting negative values 289 int i = beginIndex; 290 int result = 0; 291 int digit; 292 if (i < endIndex) { 293 digit = Character.digit(value.charAt(i++), 10); 294 if (digit < 0) { 295 throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); 296 } 297 result = -digit; 298 } 299 while (i < endIndex) { 300 digit = Character.digit(value.charAt(i++), 10); 301 if (digit < 0) { 302 throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)); 303 } 304 result *= 10; 305 result -= digit; 306 } 307 return -result; 308 } 309 310 /** 311 * Returns the index of the first character in the string that is not a digit, starting at offset. 312 */ indexOfNonDigit(String string, int offset)313 private static int indexOfNonDigit(String string, int offset) { 314 for (int i = offset; i < string.length(); i++) { 315 char c = string.charAt(i); 316 if (c < '0' || c > '9') return i; 317 } 318 return string.length(); 319 } 320 } 321