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