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