JAL-2656 Java 8 compatible test for gzipped, refactored for unit test
[jalview.git] / src / jalview / io / FileParse.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.io;
22
23 import java.io.BufferedInputStream;
24 import java.io.BufferedReader;
25 import java.io.ByteArrayInputStream;
26 import java.io.File;
27 import java.io.FileInputStream;
28 import java.io.FileReader;
29 import java.io.IOException;
30 import java.io.InputStream;
31 import java.io.InputStreamReader;
32 import java.io.Reader;
33 import java.io.StringReader;
34 import java.net.HttpURLConnection;
35 import java.net.MalformedURLException;
36 import java.net.URL;
37 import java.net.URLConnection;
38 import java.util.zip.GZIPInputStream;
39
40 import jalview.api.AlignExportSettingsI;
41 import jalview.api.AlignViewportI;
42 import jalview.api.AlignmentViewPanel;
43 import jalview.api.FeatureSettingsModelI;
44 import jalview.bin.Cache;
45 import jalview.util.MessageManager;
46 import jalview.util.Platform;
47
48 /**
49  * implements a random access wrapper around a particular datasource, for
50  * passing to identifyFile and AlignFile objects.
51  */
52 public class FileParse
53 {
54   protected static final String SPACE = " ";
55
56   protected static final String TAB = "\t";
57
58   /**
59    * text specifying source of data. usually filename or url.
60    */
61   private String dataName = "unknown source";
62
63   public File inFile = null;
64
65   private byte[] bytes; // from JavaScript
66
67   public byte[] getBytes()
68   {
69     return bytes;
70   }
71
72   /**
73    * a viewport associated with the current file operation. May be null. May
74    * move to different object.
75    */
76   private AlignViewportI viewport;
77
78   /**
79    * specific settings for exporting data from the current context
80    */
81   private AlignExportSettingsI exportSettings;
82
83   /**
84    * sequence counter for FileParse object created from same data source
85    */
86   public int index = 1;
87
88   /**
89    * separator for extracting specific 'frame' of a datasource for formats that
90    * support multiple records (e.g. BLC, Stockholm, etc)
91    */
92   protected char suffixSeparator = '#';
93
94   /**
95    * character used to write newlines
96    */
97   protected String newline = System.getProperty("line.separator");
98
99   public void setNewlineString(String nl)
100   {
101     newline = nl;
102   }
103
104   public String getNewlineString()
105   {
106     return newline;
107   }
108
109   /**
110    * '#' separated string tagged on to end of filename or url that was clipped
111    * off to resolve to valid filename
112    */
113   protected String suffix = null;
114
115   protected DataSourceType dataSourceType = null;
116
117   protected BufferedReader dataIn = null;
118
119   protected String errormessage = "UNINITIALISED SOURCE";
120
121   protected boolean error = true;
122
123   protected String warningMessage = null;
124
125   /**
126    * size of readahead buffer used for when initial stream position is marked.
127    */
128   final int READAHEAD_LIMIT = 2048;
129
130   public FileParse()
131   {
132   }
133
134   /**
135    * Create a new FileParse instance reading from the same datasource starting
136    * at the current position. WARNING! Subsequent reads from either object will
137    * affect the read position of the other, but not the error state.
138    * 
139    * @param from
140    */
141   public FileParse(FileParse from) throws IOException
142   {
143     if (from == null)
144     {
145       throw new Error(MessageManager
146               .getString("error.implementation_error_null_fileparse"));
147     }
148     if (from == this)
149     {
150       return;
151     }
152     index = ++from.index;
153     inFile = from.inFile;
154     suffixSeparator = from.suffixSeparator;
155     suffix = from.suffix;
156     errormessage = from.errormessage; // inherit potential error messages
157     error = false; // reset any error condition.
158     dataSourceType = from.dataSourceType;
159     dataIn = from.dataIn;
160     if (dataIn != null)
161     {
162       mark();
163     }
164     dataName = from.dataName;
165   }
166
167   /**
168    * Attempt to open a file as a datasource. Sets error and errormessage if
169    * fileStr was invalid.
170    * 
171    * @param fileStr
172    * @return this.error (true if the source was invalid)
173    */
174   private boolean checkFileSource(String fileStr) throws IOException
175   {
176     error = false;
177     this.inFile = new File(fileStr);
178     // check to see if it's a Jar file in disguise.
179     if (!inFile.exists())
180     {
181       errormessage = "FILE NOT FOUND";
182       error = true;
183     }
184     if (!inFile.canRead())
185     {
186       errormessage = "FILE CANNOT BE OPENED FOR READING";
187       error = true;
188     }
189     if (inFile.isDirectory())
190     {
191       // this is really a 'complex' filetype - but we don't handle directory
192       // reads yet.
193       errormessage = "FILE IS A DIRECTORY";
194       error = true;
195     }
196     if (!error)
197     {
198       try
199       {
200         dataIn = checkForGzipStream(new FileInputStream(fileStr));
201         dataName = fileStr;
202       } catch (Exception x)
203       {
204         warningMessage = "Failed to resolve " + fileStr
205                 + " as a data source. (" + x.getMessage() + ")";
206         // x.printStackTrace();
207         error = true;
208       }
209       ;
210     }
211     return error;
212   }
213
214   /**
215    * Recognise the 2-byte magic header for gzip streams
216    * 
217    * https://recalll.co/ask/v/topic/java-How-to-check-if-InputStream-is-Gzipped/555aadd62bd27354438b90f6
218    * 
219    * @param bytes
220    *          - at least two bytes
221    * @return
222    * @throws IOException 
223    */
224   public static boolean isGzipStream(InputStream input) throws IOException
225   {
226     if (!input.markSupported())
227     {
228       Cache.log.error("FileParse.izGzipStream: input stream must support mark/reset");
229       return false;
230     }
231     input.mark(4);
232     byte[] bytes = { (byte) input.read(), (byte) input.read() }; // input.readNBytes(2);
233     input.reset();
234     if (bytes.length == 2)
235     {
236       int header = (bytes[0] & 0xff) | ((bytes[1] << 8) & 0xff00);
237       return (GZIPInputStream.GZIP_MAGIC == header);
238     }
239     return false;
240   }
241
242   /**
243    * Returns a Reader for the given input after wrapping it in a buffered input
244    * stream, and then checking if it needs to be wrapped by a GZipInputStream
245    * 
246    * @param input
247    * @return
248    */
249   private BufferedReader checkForGzipStream(InputStream input)
250           throws Exception
251   {
252     // NB: stackoverflow
253     // https://stackoverflow.com/questions/4818468/how-to-check-if-inputstream-is-gzipped
254     // could use a PushBackInputStream rather than a BufferedInputStream
255     if (!input.markSupported())
256     {
257       input = new BufferedInputStream(input, 16);
258     }
259     if (isGzipStream(input))
260     {
261       return getGzipReader(input);
262     }
263     // return a buffered reader for the stream.
264     InputStreamReader isReader = new InputStreamReader(input);
265     BufferedReader toReadFrom = new BufferedReader(isReader);
266     return toReadFrom;
267   }
268
269   /**
270    * Returns a {@code BufferedReader} which wraps the input stream with a
271    * GZIPInputStream. Throws a {@code ZipException} if a GZIP format error
272    * occurs or the compression method used is unsupported.
273    * 
274    * @param inputStream
275    * @return
276    * @throws Exception
277    */
278   private BufferedReader getGzipReader(InputStream inputStream)
279           throws Exception
280   {
281     BufferedReader inData = new BufferedReader(
282             new InputStreamReader(new GZIPInputStream(inputStream)));
283     inData.mark(2048);
284     inData.read();
285     inData.reset();
286     return inData;
287   }
288
289   /**
290    * Tries to read from the given URL. If successful, saves a reader to the
291    * response in field {@code dataIn}, otherwise (on exception, or HTTP response
292    * status not 200), throws an exception.
293    * <p>
294    * If the response status includes
295    * 
296    * <pre>
297    * Content-Type : application/x-gzip
298    * </pre>
299    * 
300    * then tries to read as gzipped content.
301    * 
302    * @param urlStr
303    * @throws IOException
304    * @throws MalformedURLException
305    */
306   private void checkURLSource(String urlStr)
307           throws IOException, MalformedURLException
308   {
309     errormessage = "URL NOT FOUND";
310     URL url = new URL(urlStr);
311     URLConnection _conn = url.openConnection();
312     if (_conn instanceof HttpURLConnection)
313     {
314       HttpURLConnection conn = (HttpURLConnection) _conn;
315       int rc = conn.getResponseCode();
316       if (rc != HttpURLConnection.HTTP_OK)
317       {
318         throw new IOException(
319                 "Response status from " + urlStr + " was " + rc);
320       }
321     }
322     else
323     {
324       try
325       {
326         dataIn = checkForGzipStream(_conn.getInputStream());
327         dataName = urlStr;
328       } catch (IOException ex)
329       {
330         throw new IOException("Failed to handle non-HTTP URI stream", ex);
331       } catch (Exception ex)
332       {
333         throw new IOException(
334                 "Failed to determine type of input stream for given URI",
335                 ex);
336       }
337       return;
338     }
339     String encoding = _conn.getContentEncoding();
340     String contentType = _conn.getContentType();
341     boolean isgzipped = "application/x-gzip".equalsIgnoreCase(contentType)
342             || "gzip".equals(encoding);
343     Exception e = null;
344     InputStream inputStream = _conn.getInputStream();
345     if (isgzipped)
346     {
347       try
348       {
349         dataIn = getGzipReader(inputStream);
350         dataName = urlStr;
351       } catch (Exception e1)
352       {
353         throw new IOException(MessageManager
354                 .getString("exception.failed_to_resolve_gzip_stream"), e);
355       }
356       return;
357     }
358
359     dataIn = new BufferedReader(new InputStreamReader(inputStream));
360     dataName = urlStr;
361     return;
362   }
363
364   /**
365    * sets the suffix string (if any) and returns remainder (if suffix was
366    * detected)
367    * 
368    * @param fileStr
369    * @return truncated fileStr or null
370    */
371   private String extractSuffix(String fileStr)
372   {
373     // first check that there wasn't a suffix string tagged on.
374     int sfpos = fileStr.lastIndexOf(suffixSeparator);
375     if (sfpos > -1 && sfpos < fileStr.length() - 1)
376     {
377       suffix = fileStr.substring(sfpos + 1);
378       // System.err.println("DEBUG: Found Suffix:"+suffix);
379       return fileStr.substring(0, sfpos);
380     }
381     return null;
382   }
383
384   /**
385    * not for general use, creates a fileParse object for an existing reader with
386    * configurable values for the origin and the type of the source
387    */
388   public FileParse(BufferedReader source, String originString,
389           DataSourceType sourceType)
390   {
391     dataSourceType = sourceType;
392     error = false;
393     inFile = null;
394     dataName = originString;
395     dataIn = source;
396     try
397     {
398       if (dataIn.markSupported())
399       {
400         dataIn.mark(READAHEAD_LIMIT);
401       }
402     } catch (IOException q)
403     {
404
405     }
406   }
407
408   /**
409    * Create a datasource for input to Jalview. See AppletFormatAdapter for the
410    * types of sources that are handled.
411    * 
412    * @param file
413    *          - datasource locator/content as File or String
414    * @param sourceType
415    *          - protocol of source
416    * @throws MalformedURLException
417    * @throws IOException
418    */
419   public FileParse(Object file, DataSourceType sourceType)
420           throws MalformedURLException, IOException
421   {
422     if (file instanceof File)
423     {
424       parse((File) file, ((File) file).getPath(), sourceType, true);
425     }
426     else
427     {
428       parse(null, file.toString(), sourceType, false);
429     }
430   }
431
432   private void parse(File file, String fileStr, DataSourceType sourceType,
433           boolean isFileObject) throws IOException
434   {
435     bytes = Platform.getFileBytes(file);
436     dataSourceType = sourceType;
437     error = false;
438
439     if (sourceType == DataSourceType.FILE)
440     {
441
442       if (bytes != null)
443       {
444         // this will be from JavaScript
445         inFile = file;
446         dataIn = new BufferedReader(
447                 new InputStreamReader(new ByteArrayInputStream(bytes)));
448         dataName = fileStr;
449       }
450       else if (checkFileSource(fileStr))
451       {
452         String suffixLess = extractSuffix(fileStr);
453         if (suffixLess != null)
454         {
455           if (checkFileSource(suffixLess))
456           {
457             throw new IOException(MessageManager.formatMessage(
458                     "exception.problem_opening_file_also_tried",
459                     new String[]
460                     { inFile.getName(), suffixLess, errormessage }));
461           }
462         }
463         else
464         {
465           throw new IOException(MessageManager.formatMessage(
466                   "exception.problem_opening_file", new String[]
467                   { inFile.getName(), errormessage }));
468         }
469       }
470     }
471     else if (sourceType == DataSourceType.RELATIVE_URL)
472     {
473       // BH 2018 hack for no support for access-origin
474       bytes = Platform.getFileAsBytes(fileStr);
475       dataIn = new BufferedReader(
476               new InputStreamReader(new ByteArrayInputStream(bytes)));
477       dataName = fileStr;
478
479     }
480     else if (sourceType == DataSourceType.URL)
481     {
482       try
483       {
484         try
485         {
486           checkURLSource(fileStr);
487           if (suffixSeparator == '#')
488           {
489             extractSuffix(fileStr); // URL lref is stored for later reference.
490           }
491         } catch (IOException e)
492         {
493           String suffixLess = extractSuffix(fileStr);
494           if (suffixLess == null)
495           {
496             throw (e);
497           }
498           else
499           {
500             try
501             {
502               checkURLSource(suffixLess);
503             } catch (IOException e2)
504             {
505               errormessage = "BAD URL WITH OR WITHOUT SUFFIX";
506               throw (e); // just pass back original - everything was wrong.
507             }
508           }
509         }
510       } catch (Exception e)
511       {
512         errormessage = "CANNOT ACCESS DATA AT URL '" + fileStr + "' ("
513                 + e.getMessage() + ")";
514         error = true;
515       }
516     }
517     else if (sourceType == DataSourceType.PASTE)
518     {
519       errormessage = "PASTE INACCESSIBLE!";
520       dataIn = new BufferedReader(new StringReader(fileStr));
521       dataName = "Paste";
522     }
523     else if (sourceType == DataSourceType.CLASSLOADER)
524     {
525       errormessage = "RESOURCE CANNOT BE LOCATED";
526       InputStream is = getClass().getResourceAsStream("/" + fileStr);
527       if (is == null)
528       {
529         String suffixLess = extractSuffix(fileStr);
530         if (suffixLess != null)
531         {
532           is = getClass().getResourceAsStream("/" + suffixLess);
533         }
534       }
535       if (is != null)
536       {
537         dataIn = new BufferedReader(new InputStreamReader(is));
538         dataName = fileStr;
539       }
540       else
541       {
542         error = true;
543       }
544     }
545     else
546     {
547       errormessage = "PROBABLE IMPLEMENTATION ERROR : Datasource Type given as '"
548               + (sourceType != null ? sourceType : "null") + "'";
549       error = true;
550     }
551     if (dataIn == null || error)
552     {
553       // pass up the reason why we have no source to read from
554       throw new IOException(MessageManager.formatMessage(
555               "exception.failed_to_read_data_from_source", new String[]
556               { errormessage }));
557     }
558     error = false;
559     dataIn.mark(READAHEAD_LIMIT);
560   }
561
562   /**
563    * mark the current position in the source as start for the purposes of it
564    * being analysed by IdentifyFile().identify
565    * 
566    * @throws IOException
567    */
568   public void mark() throws IOException
569   {
570     if (dataIn != null)
571     {
572       dataIn.mark(READAHEAD_LIMIT);
573     }
574     else
575     {
576       throw new IOException(
577               MessageManager.getString("exception.no_init_source_stream"));
578     }
579   }
580
581   public String nextLine() throws IOException
582   {
583     if (!error)
584     {
585       return dataIn.readLine();
586     }
587     throw new IOException(MessageManager
588             .formatMessage("exception.invalid_source_stream", new String[]
589             { errormessage }));
590   }
591
592   /**
593    * 
594    * @return true if this FileParse is configured for Export only
595    */
596   public boolean isExporting()
597   {
598     return !error && dataIn == null;
599   }
600
601   /**
602    * 
603    * @return true if the data source is valid
604    */
605   public boolean isValid()
606   {
607     return !error;
608   }
609
610   /**
611    * closes the datasource and tidies up. source will be left in an error state
612    */
613   public void close() throws IOException
614   {
615     errormessage = "EXCEPTION ON CLOSE";
616     error = true;
617     dataIn.close();
618     dataIn = null;
619     errormessage = "SOURCE IS CLOSED";
620   }
621
622   /**
623    * Rewinds the datasource to the marked point if possible
624    * 
625    * @param bytesRead
626    * 
627    */
628   public void reset(int bytesRead) throws IOException
629   {
630     if (bytesRead >= READAHEAD_LIMIT)
631     {
632       System.err.println(String.format(
633               "File reset error: read %d bytes but reset limit is %d",
634               bytesRead, READAHEAD_LIMIT));
635     }
636     if (dataIn != null && !error)
637     {
638       dataIn.reset();
639     }
640     else
641     {
642       throw new IOException(MessageManager.getString(
643               "error.implementation_error_reset_called_for_invalid_source"));
644     }
645   }
646
647   /**
648    * 
649    * @return true if there is a warning for the user
650    */
651   public boolean hasWarningMessage()
652   {
653     return (warningMessage != null && warningMessage.length() > 0);
654   }
655
656   /**
657    * 
658    * @return empty string or warning message about file that was just parsed.
659    */
660   public String getWarningMessage()
661   {
662     return warningMessage;
663   }
664
665   public String getInFile()
666   {
667     if (inFile != null)
668     {
669       return inFile.getAbsolutePath() + " (" + index + ")";
670     }
671     else
672     {
673       return "From Paste + (" + index + ")";
674     }
675   }
676
677   /**
678    * @return the dataName
679    */
680   public String getDataName()
681   {
682     return dataName;
683   }
684
685   /**
686    * set the (human readable) name or URI for this datasource
687    * 
688    * @param dataname
689    */
690   protected void setDataName(String dataname)
691   {
692     dataName = dataname;
693   }
694
695   /**
696    * get the underlying bufferedReader for this data source.
697    * 
698    * @return null if no reader available
699    * @throws IOException
700    */
701   public Reader getReader()
702   {
703     if (dataIn != null) // Probably don't need to test for readiness &&
704                         // dataIn.ready())
705     {
706       return dataIn;
707     }
708     return null;
709   }
710
711   public AlignViewportI getViewport()
712   {
713     return viewport;
714   }
715
716   public void setViewport(AlignViewportI viewport)
717   {
718     this.viewport = viewport;
719   }
720
721   /**
722    * @return the currently configured exportSettings for writing data.
723    */
724   public AlignExportSettingsI getExportSettings()
725   {
726     return exportSettings;
727   }
728
729   /**
730    * Set configuration for export of data.
731    * 
732    * @param exportSettings
733    *          the exportSettings to set
734    */
735   public void setExportSettings(AlignExportSettingsI exportSettings)
736   {
737     this.exportSettings = exportSettings;
738   }
739
740   /**
741    * method overridden by complex file exporter/importers which support
742    * exporting visualisation and layout settings for a view
743    * 
744    * @param avpanel
745    */
746   public void configureForView(AlignmentViewPanel avpanel)
747   {
748     if (avpanel != null)
749     {
750       setViewport(avpanel.getAlignViewport());
751     }
752     // could also set export/import settings
753   }
754
755   /**
756    * Returns the preferred feature colour configuration if there is one, else
757    * null
758    * 
759    * @return
760    */
761   public FeatureSettingsModelI getFeatureColourScheme()
762   {
763     return null;
764   }
765
766   public DataSourceType getDataSourceType()
767   {
768     return dataSourceType;
769   }
770
771   /**
772    * Returns a buffered reader for the input object. Returns null, or throws
773    * IOException, on failure.
774    * 
775    * @param file
776    *          a File, or a String which is a name of a file
777    * @param sourceType
778    * @return
779    * @throws IOException
780    */
781   public BufferedReader getBufferedReader(Object file,
782           DataSourceType sourceType) throws IOException
783   {
784     BufferedReader in = null;
785     byte[] bytes;
786
787     switch (sourceType)
788     {
789     case FILE:
790       if (file instanceof String)
791       {
792         return new BufferedReader(new FileReader((String) file));
793       }
794       bytes = Platform.getFileBytes((File) file);
795       if (bytes != null)
796       {
797         return new BufferedReader(
798                 new InputStreamReader(new ByteArrayInputStream(bytes)));
799       }
800       return new BufferedReader(new FileReader((File) file));
801     case URL:
802       URL url = new URL(file.toString());
803       in = new BufferedReader(new InputStreamReader(url.openStream()));
804       break;
805     case RELATIVE_URL: // JalviewJS only
806       bytes = Platform.getFileAsBytes(file.toString());
807       if (bytes != null)
808       {
809         in = new BufferedReader(
810                 new InputStreamReader(new ByteArrayInputStream(bytes)));
811       }
812       break;
813     case PASTE:
814       in = new BufferedReader(new StringReader(file.toString()));
815       break;
816     case CLASSLOADER:
817       InputStream is = getClass().getResourceAsStream("/" + file);
818       if (is != null)
819       {
820         in = new BufferedReader(new InputStreamReader(is));
821       }
822       break;
823     }
824
825     return in;
826   }
827 }