001/*
002 * jPOS Project [http://jpos.org]
003 * Copyright (C) 2000-2026 jPOS Software SRL
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU Affero General Public License as
007 * published by the Free Software Foundation, either version 3 of the
008 * License, or (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU Affero General Public License for more details.
014 *
015 * You should have received a copy of the GNU Affero General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018
019package org.jpos.util;
020
021import java.io.ByteArrayInputStream;
022import java.io.EOFException;
023import java.io.FileNotFoundException;
024import java.io.IOException;
025import java.io.InputStream;
026import java.io.InputStreamReader;
027import java.io.PrintStream;
028import java.net.URL;
029import java.nio.charset.Charset;
030import java.util.Arrays;
031import java.util.Collections;
032import java.util.HashMap;
033import java.util.HashSet;
034import java.util.LinkedHashMap;
035import java.util.Map;
036import java.util.Map.Entry;
037import java.util.Objects;
038import java.util.Set;
039
040import org.jdom2.Element;
041import org.jdom2.JDOMException;
042import org.jdom2.input.SAXBuilder;
043import org.jpos.iso.ISOException;
044import org.jpos.iso.ISOUtil;
045import org.jpos.space.Space;
046import org.jpos.space.SpaceFactory;
047
048/**
049 * General purpose, Field Separator delimited message.
050 *
051 * <h2>How to use</h2>
052 * <p>
053 * The message format (or schema) is defined in xml files containing a schema element, with an optional id attribute, and multiple
054 * field elements. A field element is made up of the following attributes:
055 * <dl>
056 * <dt>id</dt>
057 * <dd>The name of the field. This is used in calls to {@link FSDMsg#set(String, String)}. It should be unique amongst the fields in an FSDMsg.</dd>
058 * <dt>length</dt>
059 * <dd>The maximum length of the data allowed in this field. Fixed length fields will be padded to this length. A zero length is allowed, and can
060 * be useful to define extra separator characters in the message.</dd>
061 * <dt>type</dt>
062 * <dd>The type of the included data, including an optional separator for marking the end of the field and the beginning of the next one. The data type
063 * is defined by the first char of the type, and the separator is defined by the following chars. If a field separator is specified, then no
064 * padding is done on values for this field.
065 * </dd>
066 * <dt>key</dt>
067 * <dd>If this optional attribute has a value of "true", then fields from another schema, specified by the value, are appended to this schema.</dd>
068 * <dt>separator</dt>
069 * <dd>An optional attribute containing the separator for the field. This is the preferred method of specifying the separator. See the list of optional</dd>
070 * </dl>
071 * <p>
072 * Possible types are:
073 * <dl>
074 * <dt>A</dt><dd>Alphanumeric. Padding if any is done with spaces to the right.</dd>
075 * <dt>B</dt><dd>Binary. Padding, if any, is done with zeros to the left.</dd>
076 * <dt>K</dt><dd>Constant. The value is specified by the field content. No padding is done.</dd>
077 * <dt>N</dt><dd>Numeric. Padding, if any, is done with zeros to the left.</dd>
078 * </dl>
079 * <p>
080 * Supported field separators are:
081 * <dl>
082 * <dt>FS</dt><dd>Field separator using '034' as the separator.</dd>
083 * <dt>US</dt><dd>Field separator using '037' as the separator.</dd>
084 * <dt>GS</dt><dd>Group separator using '035' as the separator.</dd>
085 * <dt>RS</dt><dd>Row separator using '036' as the separator.</dd>
086 * <dt>PIPE</dt><dd>Field separator using '|' as the separator.</dd>
087 * <dt>EOF</dt><dd>End of File - no separator character is emitted, but also no padding is done. Also if the end of file is reached
088 * parsing a message, then no exception is thrown.</dd>
089 * <dt>DS</dt><dd>A dummy separator. This is similar to EOF, but the message stream must not end before it is allowed.</dd>
090 * <dt>EOM</dt><dd>End of message separator. This reads all bytes available in the stream.
091 * </dl>
092 * <p>
093 * Key fields allow you to specify a tree of possible message formats. The key fields are the fork points of the tree.
094 * Multiple key fields are supported. It is also possible to have more key fields specified in appended schemas.
095 * </p>
096 *
097 * @author Alejandro Revila
098 * @author Mark Salter
099 * @author Dave Bergert
100 * @since 1.4.7
101 */
102@SuppressWarnings("unchecked")
103public class FSDMsg implements Loggeable, Cloneable {
104    /** Field Separator character (ASCII 0x1C). */
105    public static char FS = '\034';
106    /** Unit Separator character (ASCII 0x1F). */
107    public static char US = '\037';
108    /** Group Separator character (ASCII 0x1D). */
109    public static char GS = '\035';
110    /** Record Separator character (ASCII 0x1E). */
111    public static char RS = '\036';
112    /** End of File character (null byte). */
113    public static char EOF = '\000';
114    /** Pipe character (ASCII 0x7C). */
115    public static char PIPE = '\u007C';
116    /** End of Message marker (null byte). */
117    public static char EOM = '\000';
118
119    private static final Set<String> DUMMY_SEPARATORS = new HashSet<>(Arrays.asList("DS", "EOM"));
120    private static final String EOM_SEPARATOR = "EOM";
121    private static final int READ_BUFFER = 8192;
122
123    Map<String,String> fields;
124    Map<String, Character> separators;
125
126    String baseSchema;
127    String basePath;
128    byte[] header;
129    Charset charset;
130    private int readCount;
131
132    /**
133     * Creates a FSDMsg with a specific base path for the message format schema.
134     * @param basePath   schema path, for example: "file:src/data/NDC-" looks for a file src/data/NDC-base.xml
135     */
136    public FSDMsg (String basePath) {
137        this (basePath, "base");
138    }
139
140    /**
141     * Creates a FSDMsg with a specific base path for the message format schema, and a base schema name. For instance,
142     * FSDMsg("file:src/data/NDC-", "root") will look for a file: src/data/NDC-root.xml
143     * @param basePath   schema path
144     * @param baseSchema schema name
145     */
146    public FSDMsg (String basePath, String baseSchema) {
147        super();
148        fields = new LinkedHashMap<>();
149        separators = new LinkedHashMap<>();
150        this.basePath   = basePath;
151        this.baseSchema = baseSchema;
152        charset = ISOUtil.CHARSET;
153        readCount = 0;
154
155        setSeparator("FS", FS);
156        setSeparator("US", US);
157        setSeparator("GS", GS);
158        setSeparator("RS", RS);
159        setSeparator("EOF", EOF);
160        setSeparator("PIPE", PIPE);
161    }
162    /**
163     * Returns the base schema path used to load field definitions.
164     * @return the base schema path
165     */
166    public String getBasePath() {
167        return basePath;
168    }
169    /**
170     * Returns the base schema name used to load field definitions.
171     * @return the base schema name
172     */
173    public String getBaseSchema() {
174        return baseSchema;
175    }
176
177    /**
178     * Sets the character set used for packing and unpacking string fields.
179     * @param charset the character set to use
180     */
181    public void setCharset(Charset charset) {
182        this.charset = charset;
183    }
184
185    /**
186     * Adds or overrides a separator type/char pair.
187     * @param separatorName string identifier for the separator type (e.g. "FS", "US")
188     * @param separator the character representing this separator
189     */
190    public void setSeparator(String separatorName, char separator) {
191        separators.put(separatorName, separator);
192    }
193
194    /**
195     * Removes a previously defined separator type.
196     * @param separatorName string identifier for the separator type to remove
197     * @throws IllegalArgumentException if the separator was not previously defined
198     */
199    public void unsetSeparator(String separatorName) {
200        if (!separators.containsKey(separatorName))
201            throw new IllegalArgumentException("unsetSeparator was attempted for "+
202                      separatorName+" which was not previously defined.");
203
204        separators.remove(separatorName);
205    }
206
207    /**
208     * parse message. If the stream ends before the message is completely read, then the method adds an EOF field.
209     *
210     * @param is input stream
211     *
212     * @throws IOException on I/O error
213     * @throws JDOMException on XML parsing error
214     */
215    public void unpack (InputStream is)
216        throws IOException, JDOMException {
217        try {
218            if (is.markSupported())
219                is.mark(READ_BUFFER);
220            unpack (new InputStreamReader(is, charset), getSchema (baseSchema));
221            if (is.markSupported()) {
222                is.reset();
223                is.skip (readCount);
224                readCount = 0;
225            }
226        } catch (EOFException e) {
227            if (!fields.isEmpty())
228                fields.put ("EOF", "true");         // some fields were read, but unexpected EOF found
229            else                                    // nothing new since last msg, fields were read; no more msgs from this stream
230                throw e;                            // just rethrow the exception
231        }
232    }
233    /**
234     * parse message. If the stream ends before the message is completely read, then the method adds an EOF field.
235     *
236     * @param b message image
237     *
238     * @throws IOException on I/O error while reading the byte array
239     * @throws JDOMException on schema parsing error
240     */
241    public void unpack (byte[] b)
242        throws IOException, JDOMException {
243        unpack (new ByteArrayInputStream (b));
244    }
245
246    /**
247     * Packs this FSDMsg into its string representation.
248     * @return the packed message string
249     * @throws org.jdom2.JDOMException on schema parsing error
250     * @throws java.io.IOException on I/O error
251     * @throws ISOException on packing error
252     */
253    public String pack ()
254        throws JDOMException, IOException, ISOException
255    {
256        StringBuilder sb = new StringBuilder ();
257        pack (getSchema (baseSchema), sb);
258        return sb.toString ();
259    }
260    /**
261     * Packs this message into a byte array.
262     * @return the packed bytes
263     * @throws ISOException on pack error
264     * @throws IOException on I/O error
265     * @throws JDOMException on schema parse error
266     */
267    public byte[] packToBytes ()
268        throws JDOMException, IOException, ISOException
269    {
270        return pack().getBytes(charset);
271    }
272
273    /**
274     * Returns the formatted value for the named field.
275     * @param id        field identifier
276     * @param type      field type
277     * @param length    field length
278     * @param defValue  default value if field is absent
279     * @param separator field separator
280     * @return formatted field value
281     * @throws ISOException on error
282     */
283            protected String get (String id, String type, int length, String defValue, String separator)
284        throws ISOException
285    {
286            return get(id,type,length,defValue,separator,true);
287    }
288    /**
289     * Returns the formatted value for the named field with optional unpadding.
290     * @param id        field identifier
291     * @param type      field type
292     * @param length    field length
293     * @param defValue  default value
294     * @param separator field separator
295     * @param unPad     if true, strip padding
296     * @return formatted field value
297     * @throws ISOException on error
298     */
299    protected String get (String id, String type, int length, String defValue, String separator, boolean unPad)
300        throws ISOException
301    {
302        String value = fields.get (id);
303        if (value == null)
304            value = defValue == null ? "" : defValue;
305
306        type = type.toUpperCase ();
307        int lengthLength = 0;
308        while (type.charAt(0) == 'L') {
309            lengthLength++;
310            type = type.substring(1);
311        }
312
313        switch (type.charAt (0)) {
314            case 'N':
315                if (!isSeparated(separator)) {
316                    value = ISOUtil.zeropad (value, length);
317                } // else Leave value unpadded.
318                break;
319            case 'A':
320                if (!isSeparated(separator) && lengthLength == 0) {
321                    value = ISOUtil.strpad (value, length);
322                } // else Leave value unpadded.
323                if (value.length() > length)
324                    value = value.substring(0,length);
325                break;
326            case 'K':
327                if (defValue != null)
328                    value = defValue;
329                break;
330            case 'B':
331                if (length << 1 < value.length())
332                    throw new IllegalArgumentException("field content=" + value
333                            + " is too long to fit in field " + id
334                            + " whose length is " + length);
335
336                if (isSeparated(separator)) {
337                    // Convert but do not pad if this field ends with a
338                    // separator
339                    value = new String(ISOUtil.hex2byte(value), charset);
340                } else {
341                    value = new String(ISOUtil.hex2byte(ISOUtil.zeropad(
342                            value, length << 1).substring(0, length << 1)), charset);
343                }
344                break;
345        }
346
347        if (lengthLength == 0 && (!isSeparated(separator) || isBinary(type) || EOM_SEPARATOR.equals(separator) ||(isSeparated(separator) && !unPad)))
348          return value;
349        else {
350            if (lengthLength > 0) {
351                String format = String.format("%%0%dd%%s", lengthLength);
352                value = String.format(format, value.length(), value);
353            } else {
354                value = ISOUtil.blankUnPad(value);
355            }
356        }
357        return value;
358    }
359
360    private boolean isSeparated(String separator) {
361        /*
362         * if type's last two characters appear in our Map of separators,
363         * return true
364         */
365        if (separator == null)
366            return false;
367        else if (separators.containsKey (separator))
368            return true;
369        else if (isDummySeparator (separator))
370            return true;
371        else
372            try {
373                if (Character.isDefined(Integer.parseInt(separator,16))) {
374                    setSeparator(separator, (char)Long.parseLong(separator,16));
375                    return true;
376                }
377            } catch (NumberFormatException ignored) {
378                throw new IllegalArgumentException("Invalid separator '"+ separator + "'");
379            }
380        throw new IllegalArgumentException("isSeparated called on separator="+
381                      separator+" which was not previously defined.");
382    }
383
384    private boolean isDummySeparator(String separator) {
385        return DUMMY_SEPARATORS.contains(separator);
386    }
387
388    private boolean isBinary(String type) {
389        /*
390         * if type's first digit is a 'B' return true
391         */
392        return type.startsWith("B");
393    }
394
395    /**
396     * Tests whether a byte matches any configured separator character.
397     * @param b the byte to test
398     * @return true if the byte corresponds to any configured separator
399     */
400    public boolean isSeparator(byte b) {
401        return separators.containsValue((char) b);
402    }
403
404    private String getSeparatorType(String type) {
405        if (type.length() > 2 && !(type.charAt(0) == 'L')) {
406            return type.substring(1);
407        }
408        return null;
409    }
410
411    private char getSeparator(String separator) {
412        if (separators.containsKey(separator))
413            return separators.get(separator);
414        else if (isDummySeparator (separator)) {
415            // Dummy separator type, return 0 to indicate nothing to add.
416            return 0;
417        }
418
419        throw new IllegalArgumentException("getSeparator called on separator="+
420                      separator+" which was not previously defined.");
421    }
422
423    /**
424     * Packs this message into the given StringBuilder using the provided schema.
425     * @param schema the schema element
426     * @param sb     the target StringBuilder
427     * @throws ISOException on pack error
428     * @throws IOException on I/O error
429     * @throws JDOMException on schema error
430     */
431    protected void pack (Element schema, StringBuilder sb)
432        throws JDOMException, IOException, ISOException
433    {
434        String keyOff = "";
435        String defaultKey = "";
436        for (Element elem : schema.getChildren("field")) {
437            String id    = elem.getAttributeValue ("id");
438            int length   = Integer.parseInt (elem.getAttributeValue ("length"));
439            String type  = elem.getAttributeValue ("type");
440            // For backward compatibility, look for a separator at the end of the type attribute, if no separator has been defined.
441            String separator = elem.getAttributeValue ("separator");
442            if (type != null && separator == null) {
443                separator = getSeparatorType (type);
444            }
445            boolean unPad = true;
446            if (separator != null && elem.getAttributeValue("pack_unpad") != null) {
447                unPad = Boolean.valueOf(elem.getAttributeValue("pack_unpad"));
448            }
449            boolean key  = "true".equals (elem.getAttributeValue ("key"));
450            Map properties = key ? loadProperties(elem) : Collections.EMPTY_MAP;
451            String defValue = elem.getText();
452            // If properties were specified, then the defValue contains lots of \n and \t in it. It should just be set to the empty string, or null.
453            if (!properties.isEmpty()) {
454                defValue = defValue.replace("\n", "").replace("\t", "").replace("\r", "");
455            }
456            String value = get (id, type, length, defValue, separator, unPad);
457            sb.append (value);
458
459            if (isSeparated(separator)) {
460                char c = getSeparator(separator);
461                if (c > 0)
462                    sb.append(c);
463            }
464            if (key) {
465                String v = isBinary(type) ? ISOUtil.hexString(value.getBytes(charset)) : value;
466                keyOff = keyOff + normalizeKeyValue(v, properties);
467                defaultKey += elem.getAttributeValue ("default-key");
468            }
469        }
470        if (keyOff.length() > 0)
471            pack (getSchema (getId (schema), keyOff, defaultKey), sb);
472    }
473
474    private Map loadProperties(Element elem) {
475        Map props = new HashMap ();
476        for (Element prop : elem.getChildren ("property")) {
477                String name = prop.getAttributeValue ("name");
478                String value = prop.getAttributeValue ("value");
479                props.put (name, value);
480        }
481            return props;
482    }
483
484          private String normalizeKeyValue(String value, Map<?,String> properties) {
485        if (properties.containsKey(value)) {
486            return properties.get(value);
487        }
488        return ISOUtil.normalize(value);
489    }
490
491    /**
492     * Unpacks a message from the reader using the provided schema.
493     * @param r      the reader
494     * @param schema the schema element
495     * @throws IOException on I/O error
496     * @throws JDOMException on schema error
497     */
498    protected void unpack (InputStreamReader r, Element schema)
499        throws IOException, JDOMException {
500
501        String keyOff = "";
502        String defaultKey = "";
503        for (Element elem : schema.getChildren("field")) {
504            String id    = elem.getAttributeValue ("id");
505            int length   = Integer.parseInt (elem.getAttributeValue ("length"));
506            String type  = elem.getAttributeValue ("type").toUpperCase();
507            String separator = elem.getAttributeValue ("separator");
508            if (/* type != null && */       // can't be null or we would have NPE'ed when .toUpperCase()
509                separator == null) {
510                separator = getSeparatorType (type);
511            }
512            boolean key  = "true".equals (elem.getAttributeValue ("key"));
513            Map properties = key ? loadProperties(elem) : Collections.EMPTY_MAP;
514
515            String value = readField(r, id, length, type, separator);
516
517            if (key) {
518                keyOff = keyOff + normalizeKeyValue(value, properties);
519                defaultKey += elem.getAttributeValue ("default-key");
520            }
521
522            // constant fields should have read the constant value
523            if ("K".equals(type) && !value.equals (elem.getText()))
524                throw new IllegalArgumentException (
525                    "Field "+id
526                       + " value='"     +value
527                       + "' expected='" + elem.getText () + "'"
528                );
529        }
530        if (keyOff.length() > 0) {
531            unpack(r, getSchema (getId (schema), keyOff, defaultKey));      // recursion
532        }
533    }
534    private String getId (Element e) {
535        String s = e.getAttributeValue ("id");
536        return s == null ? "" : s;
537    }
538    /**
539     * Reads a field value from the stream.
540     * @param r         the reader
541     * @param len       maximum field length
542     * @param type      field type
543     * @param separator field separator
544     * @return the field value
545     * @throws IOException on I/O error
546     */
547    protected String read (InputStreamReader r, int len, String type, String separator)
548        throws IOException
549    {
550        StringBuilder sb = new StringBuilder();
551        char[] c = new char[1];
552        boolean expectSeparator = isSeparated(separator);
553        boolean separated = expectSeparator;
554        char separatorChar= expectSeparator ? getSeparator(separator) : '\0';
555
556        if (EOM_SEPARATOR.equals(separator)) {
557            // Grab what's left.
558            char[] rest = new char[32];
559            int con;
560            while ((con = r.read(rest, 0, rest.length)) >= 0) {
561                readCount += con;
562                if (rest.length == con)
563                    sb.append(rest);
564                else
565                    sb.append(Arrays.copyOf(rest, con));
566            }
567        } else if (isDummySeparator(separator)) {
568            /*
569             * No need to look for a separator, that is not there! Try and take
570             * len bytes from the stream.
571             */
572            for (int i = 0; i < len; i++) {
573                if (r.read(c) < 0) {
574                    break; // end of stream indicates end of field?
575                }
576                readCount++;
577                sb.append(c[0]);
578            }
579        } else {
580            int lengthLength = 0;
581            if (type != null && type.startsWith("L")) {
582                while (type.charAt(0) == 'L') {
583                    lengthLength++;
584                    type = type.substring(1);
585                }
586                if (lengthLength > 0) {
587                    char[] ll = new char[lengthLength];
588                    if (r.read(ll) != lengthLength)
589                        throw new EOFException();
590                    len = Integer.parseInt(new String(ll));
591                }
592            }
593            for (int i = 0; i < len; i++) {
594                if (r.read(c) < 0) {
595                    if (!"EOF".equals(separator))
596                        throw new EOFException();
597                    else {
598                        separated = false;
599                        break;
600                    }
601                }
602                readCount++;
603                if (expectSeparator && c[0] == separatorChar) {
604                    separated = false;
605                    break;
606                }
607                sb.append(c[0]);
608            }
609
610            if (separated && !"EOF".equals(separator)) {
611                // we still need to read the separator and account for it under readCount
612                if (r.read(c) < 0) {
613                    throw new EOFException();
614                } else {
615                    readCount++;
616                    // BBB extra check, left commented out for now (we don't want to break existing code
617//                    if (c[0] != separatorChar)
618//                        throw new IOException("Separator '"+separatorChar+"' expected "+
619//                              "but found character '"+c[0]+"' instead.");
620                }
621            }
622        }
623
624        return sb.toString();
625    }
626
627    /**
628     * Reads a named field value from the stream.
629     * @param r         the reader
630     * @param fieldName the field name
631     * @param len       maximum field length
632     * @param type      field type
633     * @param separator field separator
634     * @return the field value
635     * @throws IOException on I/O error
636     */
637    protected String readField (InputStreamReader r, String fieldName, int len,
638                                String type, String separator) throws IOException
639    {
640        String fieldValue = read (r, len, type, separator);
641
642        if (isBinary(type))
643            fieldValue = ISOUtil.hexString (fieldValue.getBytes (charset));
644        fields.put (fieldName, fieldValue);
645        // System.out.println ("++++ "+fieldName + ":" + fieldValue + " " + type + "," + isBinary(type));
646        return fieldValue;
647    }
648
649    /**
650     * Sets a field value; removes the field if value is null.
651     * @param name the field name
652     * @param value the field value, or null to remove
653     */
654    public void set (String name, String value) {
655        if (value != null)
656            fields.put (name, value);
657        else
658            fields.remove (name);
659    }
660    /**
661     * Sets the binary header bytes for this message.
662     * @param h the header bytes to set
663     */
664    public void setHeader (byte[] h) {
665        this.header = h;
666    }
667    /**
668     * Returns the binary header bytes.
669     * @return the header bytes, or null if not set
670     */
671    public byte[] getHeader () {
672        return header;
673    }
674    /**
675     * Returns the header as a hex string, or empty string if no header is set.
676     * @return the header as a hex string
677     */
678    public String getHexHeader () {
679        return header != null ? ISOUtil.hexString (header).substring (2) : "";
680    }
681    /**
682     * Returns the value of the named field.
683     * @param fieldName the field name to retrieve
684     * @return the field value, or null if not set
685     */
686    public String get (String fieldName) {
687        return fields.get (fieldName);
688    }
689    /**
690     * Returns the value of the named field, or a default if not set.
691     * @param fieldName the field name
692     * @param def the default value if not set
693     * @return the field value, or the default
694     */
695    public String get (String fieldName, String def) {
696        String s = fields.get (fieldName);
697        return s != null ? s : def;
698    }
699    /**
700     * Copies a field value from another FSDMsg into this message.
701     * @param fieldName the field to copy
702     * @param msg the source FSDMsg
703     */
704    public void copy (String fieldName, FSDMsg msg) {
705        fields.put (fieldName, msg.get (fieldName));
706    }
707    /**
708     * Copies a field value from another FSDMsg into this message, using a default if not present.
709     * @param fieldName the field to copy
710     * @param msg the source FSDMsg
711     * @param def default value if the field is not present in msg
712     */
713    public void copy (String fieldName, FSDMsg msg, String def) {
714        fields.put (fieldName, msg.get(fieldName, def));
715    }
716    /**
717     * Returns the value of the named field decoded from hex.
718     * @param name the field name containing a hex-encoded value
719     * @return decoded bytes, or null if field not set
720     */
721    public byte[] getHexBytes (String name) {
722        String s = get (name);
723        return s == null ? null : ISOUtil.hex2byte (s);
724    }
725    /**
726     * Returns the integer value of the named field, or 0 if absent or non-numeric.
727     * @param name the field name
728     * @return integer value of the field
729     */
730    @SuppressWarnings("PMD.EmptyCatchBlock")
731    public int getInt (String name) {
732        int i = 0;
733        try {
734            i = Integer.parseInt (get (name));
735        } catch (Exception ignored) { }
736        return i;
737    }
738    /**
739     * Returns the integer value of the named field, or a default if absent or non-numeric.
740     * @param name the field name
741     * @param def the default value to return if the field is absent or non-numeric
742     * @return integer value of the field or the default
743     */
744    @SuppressWarnings("PMD.EmptyCatchBlock")
745    public int getInt (String name, int def) {
746        int i = def;
747        try {
748            i = Integer.parseInt (get (name));
749        } catch (Exception ignored) { }
750        return i;
751    }
752    /**
753     * Serializes this FSDMsg to a JDOM XML Element.
754     * @return XML Element representing this message
755     */
756    public Element toXML () {
757        Element e = new Element ("message");
758        if (header != null) {
759            e.addContent (
760                new Element ("header")
761                    .setText (getHexHeader ())
762            );
763        }
764        for (String fieldName :fields.keySet()) {
765            Element inner = new Element (fieldName);
766            inner.addContent (ISOUtil.normalize (fields.get (fieldName)));
767            e.addContent (inner);
768        }
769        return e;
770    }
771    /**
772     * Returns the root schema Element for the base schema.
773     * @return the root schema Element
774     * @throws JDOMException on XML parsing error
775     * @throws IOException on I/O error
776     */
777    protected Element getSchema ()
778        throws JDOMException, IOException {
779        return getSchema (baseSchema);
780    }
781    /**
782     * Returns the root schema Element for the named message schema.
783     * @param message the schema message name
784     * @return the root schema Element
785     * @throws JDOMException on XML parsing error
786     * @throws IOException on I/O error
787     */
788    protected Element getSchema (String message)
789        throws JDOMException, IOException {
790        return getSchema (message, "", null);
791    }
792    /**
793     * Returns the root schema Element located at the given path.
794     * @param prefix    the schema path prefix
795     * @param suffix    the schema path suffix
796     * @param defSuffix the default suffix to use if the path is not found
797     * @return the root schema Element
798     * @throws JDOMException on XML parsing error
799     * @throws IOException on I/O error
800     */
801    protected Element getSchema (String prefix, String suffix, String defSuffix)
802        throws JDOMException, IOException {
803        if (basePath == null)
804            throw new NullPointerException("basePath can not be null");
805        StringBuilder sb = new StringBuilder (basePath);
806        sb.append (prefix);
807        prefix = sb.toString(); // little hack, we'll reuse later with defSuffix
808        sb.append (suffix);
809        sb.append (".xml");
810        String uri = sb.toString ();
811
812        Space sp = SpaceFactory.getSpace();
813        Element schema = (Element) sp.rdp (uri);
814        if (schema == null) {
815            schema = loadSchema(uri, defSuffix == null);
816            if (schema == null && defSuffix != null) {
817                sb = new StringBuilder (prefix);
818                sb.append (defSuffix);
819                sb.append (".xml");
820                schema = loadSchema(sb.toString(), true);
821            }
822            sp.out (uri, schema);
823        }
824        return schema;
825    }
826
827    /**
828     * Loads and parses the schema from the given URI.
829     * @param uri     the URI of the schema resource to load
830     * @param throwex if true, throw an exception if the resource is not found
831     * @return the parsed root Element, or null if not found and throwex is false
832     * @throws JDOMException on XML parsing error
833     * @throws IOException on I/O error
834     */
835    protected Element loadSchema(String uri, boolean throwex)
836        throws JDOMException, IOException {
837        SAXBuilder builder = new SAXBuilder();
838        if (uri.startsWith("jar:") && uri.length()>4) {
839            InputStream is = schemaResouceInputStream(uri.substring(4));
840            if (is == null && throwex)
841                throw new FileNotFoundException(uri + " not found");
842            else if (is != null)
843                return builder.build(is).getRootElement();
844            else
845                return null;
846        }
847
848        URL url = new URL(uri);
849        try {
850            return builder.build(url).getRootElement();
851        } catch (FileNotFoundException ex) {
852            if (throwex)
853                throw ex;
854            return null;
855        }
856    }
857
858    /**
859     * Returns an InputStream for the given classpath resource.
860     * @param resource the classpath resource path
861     * @return the InputStream, or null if the resource is not found
862     * @throws IOException on I/O error
863     * @throws JDOMException on XML error
864     */
865    protected InputStream schemaResouceInputStream(String resource)
866        throws JDOMException, IOException {
867        ClassLoader cl = Thread.currentThread().getContextClassLoader();
868        cl = cl==null ? ClassLoader.getSystemClassLoader() : cl;
869        return cl.getResourceAsStream(resource);
870    }
871
872    /**
873     * @return message's Map
874     */
875    /**
876     * Returns the underlying fields map.
877     * @return the fields map
878     */
879    public Map getMap () {
880        return fields;
881    }
882    /**
883     * Sets the underlying fields map.
884     * @param fields the fields map to set
885     */
886    public void setMap (Map fields) {
887        this.fields = fields;
888    }
889
890    @Override
891    public void dump (PrintStream p, String indent) {
892        String inner = indent + "  ";
893        p.println (indent + "<fsdmsg schema='" + basePath + baseSchema  + "'>");
894        if (header != null) {
895            append (p, "header", getHexHeader(), inner);
896        }
897        for (String f :fields.keySet())
898            append (p, f, fields.get (f), inner);
899        p.println (indent + "</fsdmsg>");
900    }
901    private void append (PrintStream p, String f, String v, String indent) {
902        p.println (indent + f + ": '" + v + "'");
903    }
904    /**
905     * Returns true if this message has a value for the named field.
906     * @param fieldName the field name to check
907     * @return true if the field is present
908     */
909    public boolean hasField(String fieldName) {
910        return fields.containsKey(fieldName);
911    }
912
913    @Override
914    public Object clone() {
915        try {
916            FSDMsg m = (FSDMsg) super.clone();
917            m.fields = (Map) ((LinkedHashMap) fields).clone();
918            return m;
919        } catch (CloneNotSupportedException e) {
920            throw new InternalError();
921        }
922    }
923    /**
924     * Copies all fields from the given message into this message.
925     * @param m the source message to merge from
926     */
927    public void merge (FSDMsg m) {
928        for (Entry<String,String> entry: m.fields.entrySet())
929             set (entry.getKey(), entry.getValue());
930    }
931
932    @Override
933    public boolean equals(Object o) {
934        if (this == o) return true;
935        if (o == null || getClass() != o.getClass()) return false;
936        FSDMsg fsdMsg = (FSDMsg) o;
937        return Objects.equals(fields, fsdMsg.fields) &&
938          Objects.equals(separators, fsdMsg.separators) &&
939          Objects.equals(baseSchema, fsdMsg.baseSchema) &&
940          Objects.equals(basePath, fsdMsg.basePath) &&
941          Arrays.equals(header, fsdMsg.header) &&
942          Objects.equals(charset, fsdMsg.charset);
943    }
944
945    @Override
946    public int hashCode() {
947        int result = Objects.hash(fields, separators, baseSchema, basePath, charset);
948        result = 31 * result + Arrays.hashCode(header);
949        return result;
950    }
951}