View Javadoc

1   package com.atlassian.scheduler.core.util;
2   
3   import com.atlassian.scheduler.cron.CronSyntaxException;
4   import com.atlassian.scheduler.cron.ErrorCode;
5   import com.google.common.collect.ImmutableMap;
6   import com.google.common.collect.ImmutableSet;
7   
8   import java.text.ParseException;
9   import java.util.Map;
10  import java.util.Set;
11  import java.util.regex.Matcher;
12  import java.util.regex.Pattern;
13  
14  import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_LAST_DOM;
15  import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_LAST_DOW;
16  import static com.atlassian.scheduler.cron.ErrorCode.COMMA_WITH_NTH_DOW;
17  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER;
18  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_HASH;
19  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_INTERVAL;
20  import static com.atlassian.scheduler.cron.ErrorCode.ILLEGAL_CHARACTER_AFTER_QM;
21  import static com.atlassian.scheduler.cron.ErrorCode.INTERNAL_PARSER_FAILURE;
22  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME;
23  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_DAY_OF_WEEK;
24  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_FIELD;
25  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_MONTH;
26  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NAME_RANGE;
27  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_MONTH;
28  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_MONTH_OFFSET;
29  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_DAY_OF_WEEK;
30  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_HOUR;
31  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_MONTH;
32  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_SEC_OR_MIN;
33  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_NUMBER_YEAR_RANGE;
34  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP;
35  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_MONTH;
36  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_DAY_OF_WEEK;
37  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_HOUR;
38  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_MONTH;
39  import static com.atlassian.scheduler.cron.ErrorCode.INVALID_STEP_SECOND_OR_MINUTE;
40  import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_FOR_BOTH_DAYS;
41  import static com.atlassian.scheduler.cron.ErrorCode.QM_CANNOT_USE_HERE;
42  import static com.atlassian.scheduler.cron.ErrorCode.QM_MUST_USE_FOR_ONE_OF_DAYS;
43  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_L;
44  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_FLAG_W;
45  import static com.atlassian.scheduler.cron.ErrorCode.UNEXPECTED_TOKEN_HASH;
46  
47  /**
48   * Maps the various {@code ParseException} messages from Quartz to our more informative and
49   * translatable exceptions in the {@link com.atlassian.scheduler.cron.CronSyntaxException} family.
50   * <p>
51   * In general, Quartz returns garbage for the error offset in the {@code ParseException}s that it
52   * throws.  This is because it has already pulled apart the cron expression with {@code StringTokenizer}
53   * and no longer knows the offset within the original string.  The damage is pretty much impossible
54   * to repair, so for most of these we simply discard the known incorrect error offset.  Correctly
55   * reporting {@code -1} to indicate that the offset isn't known is better than giving an offset that
56   * is probably wrong anyway.
57   * </p>
58   *
59   * @since v1.4
60   */
61  public class QuartzParseExceptionMapper {
62      // mappers that get called by other mappers in certain special cases
63      static final ExceptionMapper INVALID_NAME_MAPPER = new InvalidNameMapper();
64      static final ExceptionMapper INVALID_NAME_RANGE_MAPPER = ignoreValue(INVALID_NAME_RANGE);
65      static final ExceptionMapper GENERAL_PARSE_FAILURE_MAPPER = new GeneralParseFailureMapper();
66      static final ExceptionMapper UNEXPECTED_FLAG_L_MAPPER = ignoreValue(UNEXPECTED_TOKEN_FLAG_L);
67      static final ExceptionMapper UNEXPECTED_FLAG_W_MAPPER = ignoreValue(UNEXPECTED_TOKEN_FLAG_W);
68  
69      // Mappers that do an exact string match and return a simple error code with no position
70      private static final Map<String, ErrorCode> SIMPLE_MAPPERS = ImmutableMap.<String, ErrorCode>builder()
71              .put("Support for specifying both a day-of-week AND a day-of-month parameter is not implemented.",
72                      QM_MUST_USE_FOR_ONE_OF_DAYS)
73              .put("Support for specifying 'L' and 'LW' with other days of the month is not implemented",
74                      COMMA_WITH_LAST_DOM)
75              .put("Support for specifying 'L' with other days of the week is not implemented",
76                      COMMA_WITH_LAST_DOW)
77              .put("'?' can only be specfied for Day-of-Month or Day-of-Week.",  // 'specfied' typo is in Quartz
78                      QM_CANNOT_USE_HERE)
79              .put("'?' can only be specfied for Day-of-Month -OR- Day-of-Week.",  // 'specfied' typo is in Quartz
80                      QM_CANNOT_USE_FOR_BOTH_DAYS)
81              .put("Support for specifying multiple \"nth\" days is not imlemented.",  // 'imlemented' typo is in Quartz
82                      COMMA_WITH_NTH_DOW)
83              .put("Minute and Second values must be between 0 and 59", INVALID_NUMBER_SEC_OR_MIN)
84              .put("Hour values must be between 0 and 23", INVALID_NUMBER_HOUR)
85              .put("Day of month values must be between 1 and 31", INVALID_NUMBER_DAY_OF_MONTH)
86              .put("Month values must be between 1 and 12", INVALID_NUMBER_MONTH)
87              .put("Day-of-Week values must be between 1 and 7", INVALID_NUMBER_DAY_OF_WEEK)
88              .put("Offset from last day must be <= 30", INVALID_NUMBER_DAY_OF_MONTH_OFFSET)
89              .put("'/' must be followed by an integer.", INVALID_STEP)
90              .put("A numeric value between 1 and 5 must follow the '#' option",
91                      ILLEGAL_CHARACTER_AFTER_HASH)
92              .put("The 'W' option does not make sense with values larger than 31 (max number of days in a month)",
93                      INVALID_NUMBER_DAY_OF_MONTH)
94              .put("Illegal cron expression format (java.lang.IllegalArgumentException: Start year must be less than stop year)",
95                      INVALID_NUMBER_YEAR_RANGE)
96              .build();
97  
98      // Mappers that do a prefix match.
99      // Note: order is significant here, as the first match wins
100     private static final Map<String, ExceptionMapper> PREFIX_MAPPERS = ImmutableMap.<String, ExceptionMapper>builder()
101             .put("Unexpected end of expression.", new UnexpectedEndOfExpressionMapper())
102             .put("Invalid Day-of-Week value: '", new RemovePrefixAndSuffix("'", new InvalidDayOfWeekNameMapper()))
103             .put("Invalid Month value: '", new RemovePrefixAndSuffix("'", new InvalidMonthNameMapper()))
104             .put("Illegal character after '?': ",
105                     new SingleCharAfterPrefix(error(ILLEGAL_CHARACTER_AFTER_QM)))
106             .put("Illegal characters for this position: '",
107                     new RemovePrefixAndSuffix("'", new IllegalCharactersMapper()))
108             .put("Increment > 60 : ", new RemovePrefix(error(INVALID_STEP_SECOND_OR_MINUTE)))
109             .put("Increment > 31 : ", new RemovePrefix(error(INVALID_STEP_DAY_OF_MONTH)))
110             .put("Increment > 24 : ", new RemovePrefix(error(INVALID_STEP_HOUR)))
111             .put("Increment > 7 : ", new RemovePrefix(error(INVALID_STEP_DAY_OF_WEEK)))
112             .put("Increment > 12 : ", new RemovePrefix(error(INVALID_STEP_MONTH)))
113             .put("Unexpected character: ", new SingleCharAfterPrefix(error(ILLEGAL_CHARACTER)))
114             .put("Unexpected character '",
115                     new RemovePrefixAndSuffix("' after '/'", error(ILLEGAL_CHARACTER_AFTER_INTERVAL)))
116             .put("'L' option is not valid here. (pos=", new RemovePrefixAndSuffix(")", UNEXPECTED_FLAG_L_MAPPER))
117             .put("'W' option is not valid here. (pos=", new RemovePrefixAndSuffix(")", UNEXPECTED_FLAG_W_MAPPER))
118             .put("'#' option is not valid here. (pos=",
119                     new RemovePrefixAndSuffix(")", ignoreValue(UNEXPECTED_TOKEN_HASH)))
120             .put("Illegal cron expression format (java.lang.NumberFormatException: For input string: \"",
121                     new RemovePrefixAndSuffix("\")", new NumberFormatExceptionMapper()))
122             .put("Illegal cron expression format (java.lang.StringIndexOutOfBoundsException: String index out of range: ",
123                     new RemovePrefixAndSuffix(")", new StringIndexOutOfBoundsMapper()))
124             .put("Illegal cron expression format (",
125                     new RemovePrefixAndSuffix(")", GENERAL_PARSE_FAILURE_MAPPER))
126             .build();
127 
128 
129     public static CronSyntaxException mapException(final String cronExpression, final ParseException pe) {
130         final String message = pe.getMessage();
131         if (message == null) {
132             return mapGeneral(cronExpression, pe);
133         }
134 
135         final ErrorCode errorCode = SIMPLE_MAPPERS.get(message);
136         if (errorCode != null) {
137             return CronSyntaxException.builder()
138                     .cronExpression(cronExpression)
139                     .errorCode(errorCode)
140                     .cause(pe)
141                     .build();
142         }
143 
144         return mapExceptionByPrefix(cronExpression, pe);
145     }
146 
147     private static CronSyntaxException mapExceptionByPrefix(final String cronExpression, final ParseException pe) {
148         final String message = pe.getMessage();
149         for (Map.Entry<String, ExceptionMapper> entry : PREFIX_MAPPERS.entrySet()) {
150             final String prefix = entry.getKey();
151             if (message.startsWith(prefix)) {
152                 return entry.getValue().map(cronExpression, pe, prefix);
153             }
154         }
155 
156         return mapGeneral(cronExpression, pe);
157     }
158 
159     private static CronSyntaxException mapGeneral(final String cronExpression, final ParseException pe) {
160         Throwable cause = pe.getCause();
161         if (cause == null) {
162             cause = pe;
163         }
164         return CronSyntaxException.builder()
165                 .cronExpression(cronExpression)
166                 .errorCode(INTERNAL_PARSER_FAILURE)
167                 .cause(cause)
168                 .value(pe.getMessage())
169                 .build();
170     }
171 
172     static boolean startsWithNumber(final String s) {
173         if (s.isEmpty()) {
174             return false;
175         }
176         final char c = s.charAt(0);
177         return c >= '0' && c <= '9';
178     }
179 
180 
181     // The various strategies for mapping a ParseException to something meaningful
182 
183     static interface ExceptionMapper {
184         CronSyntaxException map(String cronExpression, ParseException pe, String value);
185     }
186 
187     static class RemovePrefix implements ExceptionMapper {
188         private final ExceptionMapper delegate;
189 
190         RemovePrefix(final ExceptionMapper delegate) {
191             this.delegate = delegate;
192         }
193 
194         @Override
195         public CronSyntaxException map(final String cronExpression, final ParseException pe, final String prefix) {
196             return delegate.map(cronExpression, pe, pe.getMessage().substring(prefix.length()));
197         }
198     }
199 
200     static class SingleCharAfterPrefix implements ExceptionMapper {
201         private final ExceptionMapper delegate;
202 
203         SingleCharAfterPrefix(ExceptionMapper delegate) {
204             this.delegate = delegate;
205         }
206 
207         @Override
208         public CronSyntaxException map(final String cronExpression, final ParseException pe, final String prefix) {
209             final char c = pe.getMessage().charAt(prefix.length());
210             return delegate.map(cronExpression, pe, String.valueOf(c));
211         }
212     }
213 
214     static class RemovePrefixAndSuffix implements ExceptionMapper {
215         private final String suffix;
216         private final ExceptionMapper delegate;
217 
218         RemovePrefixAndSuffix(final String suffix, final ExceptionMapper delegate) {
219             this.suffix = suffix;
220             this.delegate = delegate;
221         }
222 
223         @Override
224         public CronSyntaxException map(final String cronExpression, final ParseException pe, final String prefix) {
225             final String value = pe.getMessage();
226 
227             final int pos = value.lastIndexOf(suffix);
228             if (pos > prefix.length()) {
229                 return delegate.map(cronExpression, pe, value.substring(prefix.length(), pos));
230             }
231 
232             // Hmmm...  Do the best that we can with it, then...
233             return delegate.map(cronExpression, pe, value.substring(prefix.length()));
234         }
235     }
236 
237     static class ErrorCodeMapper implements ExceptionMapper {
238         private final ErrorCode errorCode;
239 
240         ErrorCodeMapper(final ErrorCode errorCode) {
241             this.errorCode = errorCode;
242         }
243 
244         @Override
245         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
246             return CronSyntaxException.builder()
247                     .cronExpression(cronExpression)
248                     .errorCode(errorCode)
249                     .cause(pe)
250                     .value(value)
251                     .build();
252         }
253     }
254 
255     static class IgnoreValue extends ErrorCodeMapper {
256         IgnoreValue(ErrorCode errorCode) {
257             super(errorCode);
258         }
259 
260         @Override
261         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
262             return super.map(cronExpression, pe, null);
263         }
264     }
265 
266 
267     static class UnexpectedEndOfExpressionMapper implements ExceptionMapper {
268         @Override
269         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
270             return CronSyntaxException.builder()
271                     .cronExpression(cronExpression)
272                     .errorCode(ErrorCode.UNEXPECTED_END_OF_EXPRESSION)
273                     .errorOffset(cronExpression.length())
274                     .cause(pe)
275                     .build();
276         }
277     }
278 
279     /**
280      * Quartz found something it thinks is a name in a position that doesn't allow names.
281      * We want to make it clearer what the problem is than to just say it is illegal characters,
282      * but unfortunately we can also get this in the day-of-month field when it contains a
283      * malformed {@code "L-"} expression, and there is a better error to report for that case.
284      */
285     static class IllegalCharactersMapper implements ExceptionMapper {
286         private static final ExceptionMapper WRONG_FIELD = error(INVALID_NAME_FIELD);
287 
288         @Override
289         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
290             final int hyphen = value.indexOf('-');
291             final String s = (hyphen != -1) ? value.substring(0, hyphen) : value;
292 
293             if ("L".equals(s)) {
294                 return UNEXPECTED_FLAG_L_MAPPER.map(cronExpression, pe, null);
295             }
296 
297             if ("W".equals(s)) {
298                 return UNEXPECTED_FLAG_W_MAPPER.map(cronExpression, pe, null);
299             }
300 
301             return WRONG_FIELD.map(cronExpression, pe, value);
302         }
303     }
304 
305     /**
306      * Quartz hit a StringIndexOutOfBoundsException because it assumed a name was at least 3 characters long
307      * when it wasn't.
308      * <p>
309      * Since Quartz tokenizes the expression into fields before this happens, the error offset can't be trusted
310      * and we don't really know where in the expression the error was detected.  This uses regex to find the
311      * probable culprit, which is going to be a sequence of letters that is 2 or shorter.  The sequences
312      * "L", "W", and "LW" are valid in some settings; we ignore them rather than try to get too fancy.
313      * If we can't figure out what's wrong, then a general parser failure is reported.  That is, after all,
314      * the real problem, here.
315      * </p>
316      */
317     static class InvalidNameMapper implements ExceptionMapper {
318         private static final Pattern REGEX_FIND_NAMES = Pattern.compile("[A-Z]+");
319         private static final Pattern REGEX_FIND_BAD_FLAG_L = Pattern.compile("[^A-Z]L-( |\t|$)");
320         private static final Set<String> L_AND_W_FLAGS = ImmutableSet.of("L", "W", "LW");
321 
322         @Override
323         public CronSyntaxException map(String cronExpression, ParseException pe, String ignored) {
324             Matcher matcher = REGEX_FIND_NAMES.matcher(cronExpression);
325             while (matcher.find()) {
326                 final String name = matcher.group(0);
327                 if (name.length() < 3 && !L_AND_W_FLAGS.contains(name)) {
328                     return CronSyntaxException.builder()
329                             .cronExpression(cronExpression)
330                             .errorCode(INVALID_NAME)
331                             .errorOffset(matcher.start())
332                             .value(name)
333                             .cause(pe)
334                             .build();
335                 }
336             }
337 
338             matcher = REGEX_FIND_BAD_FLAG_L.matcher(cronExpression);
339             if (matcher.find()) {
340                 return CronSyntaxException.builder()
341                         .cronExpression(cronExpression)
342                         .errorCode(UNEXPECTED_TOKEN_FLAG_L)
343                         .errorOffset(matcher.start() + 1)
344                         .cause(pe)
345                         .build();
346             }
347 
348             // Nothing that we recognize...  oh well...
349             return mapGeneral(cronExpression, pe);
350         }
351     }
352 
353     static class InvalidDayOfWeekNameMapper implements ExceptionMapper {
354         private static final ExceptionMapper BAD_DAY_OF_WEEK = error(INVALID_NAME_DAY_OF_WEEK);
355 
356         @Override
357         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
358             // MON-4 => INVALID_NAME_RANGE instead of INVALID_NAME_DAY_OF_WEEK
359             final ExceptionMapper mapper = startsWithNumber(value) ? INVALID_NAME_RANGE_MAPPER : BAD_DAY_OF_WEEK;
360             return mapper.map(cronExpression, pe, value);
361         }
362     }
363 
364     static class InvalidMonthNameMapper implements ExceptionMapper {
365         private static final ExceptionMapper BAD_MONTH = error(INVALID_NAME_MONTH);
366 
367         @Override
368         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
369             // FEB-4 => INVALID_NAME_RANGE instead of INVALID_NAME_MONTH
370             final ExceptionMapper mapper = startsWithNumber(value) ? INVALID_NAME_RANGE_MAPPER : BAD_MONTH;
371             return mapper.map(cronExpression, pe, value);
372         }
373     }
374 
375     // This happens when there is supposed to be a number and Quartz tries to parse it without checking.
376     // Examples are in a range where the first half was numeric, like 4-XYZ, or the step interval, such
377     // as 3-7/XYZ.  We try to identify these two cases and report the correct specific error for them.
378     static class NumberFormatExceptionMapper implements ExceptionMapper {
379         private static final Pattern REGEX_FIND_BAD_RANGE = Pattern.compile("[0-9]+-([A-Za-z]+)");
380         private static final Pattern REGEX_FIND_BAD_STEP = Pattern.compile("/[^0-9]");
381 
382         @Override
383         public CronSyntaxException map(String cronExpression, ParseException pe, String ignored) {
384             final Matcher range = REGEX_FIND_BAD_RANGE.matcher(cronExpression);
385             final Matcher step = REGEX_FIND_BAD_STEP.matcher(cronExpression);
386 
387             if (range.find()) {
388                 if (step.find() && step.start() < range.start()) {
389                     return mapStep(cronExpression, pe, step);
390                 }
391                 return mapRange(cronExpression, pe, range);
392             }
393 
394             if (step.find()) {
395                 return mapStep(cronExpression, pe, step);
396             }
397 
398             // Nothing that we recognize...  oh well...
399             return mapGeneral(cronExpression, pe);
400         }
401 
402         private static CronSyntaxException mapRange(String cronExpression, ParseException pe, Matcher range) {
403             return CronSyntaxException.builder()
404                     .cronExpression(cronExpression)
405                     .errorCode(INVALID_NAME_RANGE)
406                     .errorOffset(range.start(1))
407                     .cause(pe)
408                     .build();
409         }
410 
411         private static CronSyntaxException mapStep(String cronExpression, ParseException pe, Matcher step) {
412             return CronSyntaxException.builder()
413                     .cronExpression(cronExpression)
414                     .errorCode(INVALID_STEP)
415                     .errorOffset(step.start() + 1)
416                     .cause(pe)
417                     .build();
418         }
419     }
420 
421     // The Quartz cron expression parser hits this when it's expecting an English name for a month or
422     // day-of-week, but there aren't enough characters to complete the sequence (it doesn't bother to
423     // check first).  It can also hit it for "L-" by itself.  Ugh!
424     static class StringIndexOutOfBoundsMapper implements ExceptionMapper {
425         @Override
426         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
427             final int len = toInt(value);
428             switch (len) {
429                 case 1:
430                 case 2:
431                 case 3:
432                     return INVALID_NAME_MAPPER.map(cronExpression, pe, null);
433 
434                 case 5:
435                 case 6:
436                 case 7:
437                     return INVALID_NAME_RANGE_MAPPER.map(cronExpression, pe, null);
438             }
439 
440             // Nothing that we recognize...  oh well...
441             return mapGeneral(cronExpression, pe);
442         }
443     }
444 
445     static class GeneralParseFailureMapper implements ExceptionMapper {
446         @Override
447         public CronSyntaxException map(String cronExpression, ParseException pe, String value) {
448             return mapGeneral(cronExpression, pe);
449         }
450     }
451 
452     static int toInt(String s) {
453         try {
454             if (s != null) {
455                 return Integer.parseInt(s);
456             }
457         } catch (NumberFormatException nfe) {
458             // Doesn't matter; just make sure it won't match a known case
459         }
460         return -1;
461     }
462 
463     private static ErrorCodeMapper error(ErrorCode errorCode) {
464         return new ErrorCodeMapper(errorCode);
465     }
466 
467     private static IgnoreValue ignoreValue(ErrorCode errorCode) {
468         return new IgnoreValue(errorCode);
469     }
470 }