001package com.randomnoun.common;
002
003/* (c) 2013 randomnoun. All Rights Reserved. This work is licensed under a
004 * BSD Simplified License. (http://www.randomnoun.com/bsd-simplified.html)
005 */
006
007import java.text.ParseException;
008import java.util.*;
009
010import jakarta.servlet.http.HttpServletRequest;
011
012/**
013 * The ErrorList class is a a fairly generic class for containing validation errors for
014 * input forms, similar to the struts ActionErrors class. Each error within this class can
015 * contain a 'short' and 'long' description, where the short description is normally a 
016 * categorisation of the error and the longer description describes what has happened and
017 * how to fix the problem; (e.g. shortText="Missing field", 
018 * longText="The field 'id' is mandatory. Please enter a value for this field."). The short
019 * description is normally rendered by <code>errorHeader.jsp</code> in bold before the long
020 * description. An individual error also may contain a list of fields that it applies to
021 * (e.g. a 'mandatory inclusive' error may apply to several fields at once), and a
022 * severity (normally set to {@link #SEVERITY_INVALID}. Errors are displayed by the
023 * <code>errorHeader.jsp</code> JSP, which is typically included at the top of any page
024 * that contains an input form. An error without a severity supplied is assumed to be 
025 * of SEVERITY_INVALID, and an error without any fields supplied is assumed to be
026 * associated with the entire form, rather than a specific set of fields.
027 * 
028 * <p>Errors are inserted into an ErrorList by using one of the addError methods:
029 * <ul>
030 * <li> {@link #addError(String, String)} - add an invalid form error
031 * <li> {@link #addError(String, String, int)} - add an form error with a specific severity
032 * <li> {@link #addError(String, String, String)} - add an invalid field error  
033 * <li> {@link #addError(String, String, String, int)} - add an invalid field error with a specific severity 
034 * </ul>
035 * 
036 * <p>Code that uses an ErrorList to perform validation may attach an object to the
037 * errorList instance to perform standard validations and to localise error messages.
038 *
039 * @author knoxg
040 */
041public class ErrorList extends ArrayList<ErrorList.ErrorData>
042    implements java.io.Serializable
043{
044
045    /** Generated serialVersionUID */
046        private static final long serialVersionUID = 9113362689694606840L;
047
048        /** Severity level indicating 'not an error' (e.g. informational only). Used to indicate successful operations */
049    public static final int SEVERITY_OK = 0; // Not an error
050
051    /** invalid user-supplied data; end-user can fix situation */
052    public static final int SEVERITY_INVALID = 1;
053
054    /** invalid system-supplied data; end-user cannot fix situation */
055    public static final int SEVERITY_ERROR = 2;
056
057    /** internal error (e.g. EJB/LDAP connection failure) */
058    public static final int SEVERITY_FATAL = 3;
059
060    /** unrecoverable internal error (can't think of anything here, but the world ending could be one */
061    public static final int SEVERITY_PANIC = 4;
062
063    // these were created after the 5 above, which is why they have higher ID numbers
064    // at a later stage will renumber these so that
065    // OK < INFO < WARNING < INVALID < ERROR < FATAL < PANIC
066    
067    /** information message; can be used in addition to SEVERITY_OK for additional text */
068    public static final int SEVERITY_INFO = 5;
069
070    /** possibly incorrect user-supplied data; operation still succeeds but may return incorrect results */
071    public static final int SEVERITY_WARNING = 6;
072
073    
074    // the thing we're validating
075    private transient Object attachedObject;
076
077    // contains error messages
078    private transient ResourceBundle attachedBundle;
079    private transient String bundleFormat;
080    private transient String attachedFieldFormat;
081    private transient Locale locale;
082
083    /** ErrorInfo inner class - contains information related to
084     *  a single error
085     */
086    public static class ErrorData
087        extends HashMap<String, Object>
088    {
089        /** Generated serialVersionUID */
090                private static final long serialVersionUID = -176737615399095851L;
091
092                /**
093         * Creates a new ErrorInfo object.
094         *
095         * @param shortText a short, descriptive string for this error
096         * @param longText  a more lengthy description of this error
097         * @param errorField    comma-separated list of field names that caused
098         *                    this error
099         * @param severity    the severity of this error
100         */
101        public ErrorData(String shortText, String longText, String errorField,
102            int severity)
103        {
104            super();
105            put("shortText", shortText);
106            put("longText", longText);
107            put("field", errorField);
108            put("severity", Integer.valueOf(severity));
109        }
110
111        /** Retrieves the type for this error
112         *  @return   the type for this error
113         */
114        public String getShortText()
115        {
116            return (String)get("shortText");
117        }
118
119        /** Retrieves the description for this error
120         *  @return   the description for this error
121         */
122        public String getLongText()
123        {
124            return (String)get("longText");
125        }
126
127        /** Retrieves the description for this error, with newlines converted to &lt;br/&gt;s. 
128         *   When displaying with &lt;c:out&gt;, set the escapeXml attribute to false
129         *   
130         *  @return   the description for this error
131         */
132        public String getLongTextWithNewlines()
133        {
134                String longText = (String) get("longText");
135                longText=Text.escapeHtml(longText);
136                longText=Text.replaceString(longText, "\n", "<br/>");
137            return longText;
138        }
139
140        
141        /** Retrieves a comma-separated list of fields that caused this error
142         *  @return   a comma-separated list of fields that caused this error
143         */
144        public String getField()
145        {
146            return (String)get("field");
147        }
148
149        /** Retrieves the severity of this error
150         *  @return   the severity of this error
151         */
152        public int getSeverity()
153        {
154            return ((Integer)get("severity")).intValue();
155        }
156
157        /** Retrieves a string representation of this error
158         *  @return   a string representation of this error
159         */
160        public String toString()
161        {
162            return "{shortText='" + getShortText() + "', longText='" + getLongText() +
163              "', fields='" + getField() + "', severity=" + getSeverity() + "}";
164        }
165    }
166
167    /**
168     * Create a new, empty ErrorData object.
169     */
170    public ErrorList()
171    {
172        super();
173    }
174
175    /**
176     * Adds an error. This is the "real" addError() method; all others delegate to this
177     * one.
178     *
179     * @param errorField  a comma-separated list of field names that caused this error
180     * @param shortText a short string conveying the error type (e.g. 'Missing field')
181     * @param longText  a longer string describing the nature of the error and how to resolve it
182     *   (e.g. 'The field 'id' is mandatory. Please enter a value for this field.')
183     * @param severity    the severity of this error (one of the SEVERITY_* constants of this class).
184     *
185     * @see SEVERITY_INVALID
186     */
187    public void addError(String errorField, String shortText, String longText,
188        int severity)
189    {
190        // if we have an attachedFieldFormat, then format each field to this format
191        if (attachedFieldFormat != null && errorField != null) {
192            try {
193                List<String> fields = Text.parseCsv(errorField);
194                errorField = "";
195                for (Iterator<String> i = fields.iterator(); i.hasNext();) {
196                    String field = i.next();
197                    errorField = errorField + getFieldName(field) +
198                        (i.hasNext() ? "," : "");
199                }
200            } catch (ParseException pe) {
201                throw (IllegalArgumentException)new IllegalArgumentException(
202                    "Invalid errorField list '" + errorField + "'").initCause(pe);
203            }
204        }
205        super.add(new ErrorData(shortText, longText, errorField, severity));
206    }
207
208    /**
209     * As per {@link #addError(String, String, String, int)}, with a default
210     * severity of {@link #SEVERITY_ERROR}.
211     *
212     * @param errorField  a comma-separated list of field names that caused this error
213     * @param shortText a short string conveying the error type (e.g. 'Missing field')
214     * @param longText  a longer string describing the nature of the error and how to resolve it
215     *   (e.g. 'The field 'id' is mandatory. Please enter a value for this field.')
216     */
217    public void addError(String errorField, String shortText, String longText)
218    {
219        addError(errorField, shortText, longText, SEVERITY_ERROR);
220    }
221
222    /**
223     * Adds an error that isn't associated with any particular field
224     *
225     * @param shortText a short string conveying the error type (e.g. 'Missing field')
226     * @param longText  a longer string describing the nature of the error and how to resolve it
227     *   (e.g. 'The field 'id' is mandatory. Please enter a value for this field.')
228     * @param severity  the severity of this error (one of the SEVERITY_* constants of this class)
229     *
230     */
231    public void addError(String shortText, String longText, int severity)
232    {
233        addError("", shortText, longText, severity);
234    }
235
236    /**
237     * Adds an error that isn't associated with any particular field, with a default
238     * severity of {@link #SEVERITY_ERROR}.
239     *
240     * @param shortText a short string conveying the error type (e.g. 'Missing field')
241     * @param longText  a longer string describing the nature of the error and how to resolve it
242     *   (e.g. 'The field 'id' is mandatory. Please enter a value for this field.')
243     */
244    public void addError(String shortText, String longText)
245    {
246        addError("", shortText, longText, SEVERITY_ERROR);
247    }
248
249    /**
250     * Resets all errors contained within this object
251     */
252    public void clearErrors()
253    {
254        super.clear();
255    }
256
257    /**
258     * Appends the errors contained within another ErrorData into this one.
259     *
260     * @param errorList
261     *
262     * @return <b>true</b> if there were any additional errors to embed and at least
263     *   one of the errors had a severity of SEVERITY_ERROR or higher (worse),
264     *   <b>false</b> otherwise.
265     */
266    public boolean addErrors(ErrorList errorList)
267    {
268        if (errorList == null) {
269            return false;
270        }
271        if (errorList.size() == 0) {
272            return false;
273        }
274        int maxSeverity = Integer.MIN_VALUE;
275        int severity;
276
277        for (int i = 0; i < errorList.size(); i++) {
278            severity = errorList.getSeverityAt(i);
279            add(errorList.get(i));
280            if (severity > maxSeverity) {
281                maxSeverity = severity;
282            }
283        }
284
285        if (maxSeverity >= SEVERITY_ERROR) {
286            return true;
287        } else {
288            return false;
289        }
290    }
291
292    /**
293     * Returns true if an error has occured on the CGI field
294     * passed as a parameter to this method
295     *
296     * @return true if an error occured, false if not.
297     */
298    public boolean hasErrorOn(String field)
299    {
300        ErrorData errorInfo;
301
302        for (Iterator<ErrorData> i = super.iterator(); i.hasNext(); ) {
303            errorInfo = i.next();
304            if (errorInfo.getField()!=null) {
305                    String[] errorFields = errorInfo.getField().split(",");
306                    for (int j = 0; j < errorFields.length; j++) {
307                        if (errorFields[j].equals(field)) {
308                            return true;
309                        }
310                    }
311            }
312        }
313        return false;
314    }
315
316    /**
317     * Returns true if there are any errors in this object
318     *
319     * @return True if the number of errors &gt; 0
320     */
321    public boolean hasErrors()
322    {
323        return size() > 0;
324    }
325
326    /**
327     * Returns true if there are any errors of the specified severity or higher
328     *
329     * @param severity a SEVERITY_* constant
330     *
331     * @return True if the number of errors at the specified severity or higher &gt; 0
332     */
333    public boolean hasErrors(int severity)
334    {
335        for (Iterator<ErrorData> i = this.iterator(); i.hasNext(); ){
336            ErrorData errorData = (ErrorData) i.next();
337            if (errorData.getSeverity() >= severity ) { 
338                return true;
339            }
340        }
341        return false;
342    }
343
344
345    /**
346     * Returns the maximum severity of all current errors.
347     *
348     * @return the severity ranking of the most severe error, or -1 if
349     *   there are no errors contained within this ErrorData object.
350     */
351    public int maxErrorSeverity()
352    {
353        int maxSeverity = -1;
354        int curSeverity;
355
356        for (int i = 0; i < size(); i++) {
357            curSeverity = getSeverityAt(i);
358            if (curSeverity > maxSeverity) {
359                maxSeverity = curSeverity;
360            }
361        }
362        return maxSeverity;
363    }
364
365    /**
366     * Returns the number of errors in this object
367     *
368     * @return The number of errors in this object
369     */
370    public int size() {
371        return super.size();
372    }
373
374    /**
375     * Same as {@link #size()}. Only here to allow us to access the object in JSTL
376     *
377     * @return The number of errors in this object
378     */
379    public int getSize() {
380        return size();
381    }
382
383    /**
384     * Returns the shortText string of the pos'th error
385     */
386    public String getShortTextAt(int pos) {
387        return ((ErrorData)super.get(pos)).getShortText();
388    }
389
390    /**
391     * Returns the longText of the pos'th error
392     */
393    public String getLongTextAt(int pos) {
394        return ((ErrorData)super.get(pos)).getLongText();
395    }
396
397    /**
398     * Returns the field of the pos'th error
399     */
400    public String getFieldAt(int pos) {
401        return ((ErrorData)super.get(pos)).getField();
402    }
403
404    /**
405     * Returns the severity of the pos'th error
406     */
407    public int getSeverityAt(int pos) {
408        return ((ErrorData)super.get(pos)).getSeverity();
409    }
410
411    /**
412     * Return all the errors within this object in a single string.
413     * Suitable for inclusion within email alarms, logs etc...
414     *
415     * @return Newline-separated list of errors
416     */
417    public String toString() {
418        StringBuffer sb = new StringBuffer();
419        ErrorData errorData;
420        for (Iterator<ErrorData> i = super.iterator(); i.hasNext(); ) {
421            errorData = (ErrorData)i.next();
422
423            sb.append("#");
424            sb.append(errorData.getShortText() + " - ");
425            sb.append(errorData.getLongText());
426            if (!Text.isBlank(errorData.getField())) {
427                sb.append(" [" + errorData.getField() + "]\n");
428            }
429        }
430        return sb.toString();
431    }
432
433    /** Attaches an object to be validated to this ErrorList.
434     *
435     * @param attachedObject a POJO, a HttpServletRequest, or a Map
436     *   (e.g. request.getParameterMap()). It identifies  where the validated data
437     *   is coming from.
438     * @param attachedBundle a bundle where the field names for this object are
439     *   to be retrieved from.
440     * @param bundleFormat a MessageFormat used to to retrieve field names from
441     *   attachedBundle. The "{0}" placeholder in this String is replaced with
442     *   the name of the field being validated.
443     */
444
445    // first parameter of this method can be 
446    // second method 
447    public void setValidatedObject(Object attachedObject, String attachedFieldFormat,
448        ResourceBundle attachedBundle, String bundleFormat, Locale locale)
449    {
450        if (attachedObject==null) { throw new NullPointerException("null attachedObject"); }
451        if (attachedFieldFormat==null) { throw new NullPointerException("null fieldFormat"); }
452        if (attachedBundle==null) { throw new NullPointerException("null attachedBundle"); } 
453        if (bundleFormat==null) { throw new NullPointerException("null bundleFormat"); }
454        if (locale==null) { throw new NullPointerException("null locale"); }
455
456        this.attachedObject = attachedObject;
457        this.attachedFieldFormat = attachedFieldFormat;
458        this.attachedBundle = attachedBundle;
459        this.bundleFormat = bundleFormat;
460        this.locale = locale;
461    }
462
463    public void resetValidatedObject()
464    {
465        this.attachedObject = null;
466        this.attachedFieldFormat = null;
467        this.attachedBundle = null;
468        this.bundleFormat = null;
469    }
470
471    /**
472     * The object containing the data being validated
473     *
474     * @return the object containing the data being validated
475     */
476    public Object getObject()
477    {
478        return attachedObject;
479    }
480    
481    /**
482     * The locale in which validation messages will be localised
483     *  
484     * @return The locale in which validation messages will be localised
485     */
486    public Locale getLocale()
487    {
488        return locale;
489    }
490
491    /** Returns the value of a field from the attached object
492     * 
493     * @param name field name
494     * 
495     * @return field value
496     */
497    public String getFieldValue(String name) {
498        // use reflection
499        if (attachedObject instanceof HttpServletRequest) {
500            return ((HttpServletRequest)attachedObject).getParameter(name);
501        }
502
503        Object obj = Struct.getValue(attachedObject, name);
504        if (obj == null) {
505            return null;
506        } else if (obj instanceof String[]) {
507            return ((String[])obj)[0];
508        } else if (obj instanceof String) {
509            return (String)obj;
510        } else {
511            throw new IllegalStateException("Cannot retrieve non-string value '" + name +
512                "' (found class " + obj.getClass().getName() + ")");
513        }
514    }
515
516    /**
517     * Returns the bundle used for localising validation messages 
518     *
519     * @return the bundle used for localising validation messages
520     */
521    public ResourceBundle getBundle()
522    {
523        return attachedBundle;
524    }
525
526    /**
527     * Returns a localised form of the supplied field name, to be used within
528     * generic validation messages
529     *
530     * @param field field name
531     *
532     * @return localised form of field name
533     */
534    public String getLocalisedFieldName(String field)
535    {
536        return attachedBundle.getString(Text.replaceString(bundleFormat, "{0}", field));
537    }
538
539    public String getFieldName(String field)
540    {
541        if (attachedFieldFormat == null) {
542            return field;
543        } else {
544            return Text.replaceString(attachedFieldFormat, "{0}", field);
545        }
546    }
547
548    /** Removes validation metadata from this object 
549     * (i.e. attached objects, bundles, bundleFormats)
550     *  
551     */
552    public void unattach()
553    {
554        this.attachedObject = null;
555        this.attachedBundle = null;
556        this.bundleFormat = null;
557    }
558
559    
560    /** Return the JSON representation of this object
561     *  
562     * @return the JSON representation of this object
563     */
564    public String toJSON() {
565        StringBuffer sb = new StringBuffer();
566        ErrorList.ErrorData errorData;
567        sb.append("[");
568        for (int i=0; i<this.size(); i++) {
569                errorData = (ErrorData) get(i);
570                if (i>0) { sb.append(","); }
571                sb.append(
572                  "{\"shortText\":\"" + Text.escapeJavascript(errorData.getShortText()) + "\"," +
573                  "\"longText\":\"" + Text.escapeJavascript(errorData.getLongText()) + "\"," +
574                  "\"fields\":\"" + Text.escapeJavascript(errorData.getField()) + "\"," +
575                  "\"severity\":" + errorData.getSeverity() + "}");
576        }
577        sb.append("]");
578        return sb.toString();
579    }
580
581}