more iterators out; some time checks
[jalview.git] / src / jalview / ext / ensembl / EnsemblRestClient.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.ext.ensembl;
22
23 import jalview.bin.Cache;
24 import jalview.javascript.json.JSON;
25 import jalview.util.JSONUtils;
26 import jalview.util.Platform;
27 import jalview.util.StringUtils;
28
29 import java.io.BufferedReader;
30 import java.io.DataOutputStream;
31 import java.io.IOException;
32 import java.io.InputStream;
33 import java.io.InputStreamReader;
34 import java.io.Reader;
35 import java.net.HttpURLConnection;
36 import java.net.MalformedURLException;
37 import java.net.ProtocolException;
38 import java.net.URL;
39 import java.util.HashMap;
40 import java.util.List;
41 import java.util.Map;
42
43 import javax.ws.rs.HttpMethod;
44
45 import org.json.simple.parser.ParseException;
46
47 /**
48  * Base class for Ensembl REST service clients
49  * 
50  * @author gmcarstairs
51  */
52 abstract class EnsemblRestClient extends EnsemblSequenceFetcher
53 {
54         
55   static {
56           Cache.addJ2SDirectDatabaseCall("http://rest.ensembl");
57           Cache.addJ2SDirectDatabaseCall("https://rest.ensembl");
58   }
59   
60   private static final int DEFAULT_READ_TIMEOUT = 5 * 60 * 1000; // 5 minutes
61
62   private static final int CONNECT_TIMEOUT_MS = 10 * 1000; // 10 seconds
63
64   private static final int MAX_RETRIES = 3;
65
66   private static final int HTTP_OK = 200;
67
68   private static final int HTTP_OVERLOAD = 429;
69
70   /*
71    * update these constants when Jalview has been checked / updated for
72    * changes to Ensembl REST API (ref JAL-2105)
73    * @see https://github.com/Ensembl/ensembl-rest/wiki/Change-log
74    * @see http://rest.ensembl.org/info/rest?content-type=application/json
75    */
76   private static final String LATEST_ENSEMBLGENOMES_REST_VERSION = "7.0";
77
78   private static final String LATEST_ENSEMBL_REST_VERSION = "7.0";
79
80   private static final String REST_CHANGE_LOG = "https://github.com/Ensembl/ensembl-rest/wiki/Change-log";
81
82   private static Map<String, EnsemblData> domainData;
83
84   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
85
86   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
87
88   protected static final String CONTENT_TYPE_JSON = "?content-type=application/json";
89
90   static
91   {
92     domainData = new HashMap<>();
93     domainData.put(DEFAULT_ENSEMBL_BASEURL,
94             new EnsemblData(DEFAULT_ENSEMBL_BASEURL, LATEST_ENSEMBL_REST_VERSION));
95     domainData.put(DEFAULT_ENSEMBL_GENOMES_BASEURL, new EnsemblData(
96             DEFAULT_ENSEMBL_GENOMES_BASEURL, LATEST_ENSEMBLGENOMES_REST_VERSION));
97   }
98
99   protected volatile boolean inProgress = false;
100
101   /**
102    * Default constructor to use rest.ensembl.org
103    */
104   public EnsemblRestClient()
105   {
106     super();
107
108     /*
109      * initialise domain info lazily
110      */
111     if (!domainData.containsKey(ensemblDomain))
112     {
113       domainData.put(ensemblDomain,
114               new EnsemblData(ensemblDomain, LATEST_ENSEMBL_REST_VERSION));
115     }
116     if (!domainData.containsKey(ensemblGenomesDomain))
117     {
118       domainData.put(ensemblGenomesDomain, new EnsemblData(
119               ensemblGenomesDomain, LATEST_ENSEMBLGENOMES_REST_VERSION));
120     }
121   }
122
123   /**
124    * Constructor given the target domain to fetch data from
125    * 
126    * @param d
127    */
128   public EnsemblRestClient(String d)
129   {
130     setDomain(d);
131   }
132
133   @Override
134   public boolean queryInProgress()
135   {
136     return inProgress;
137   }
138
139   @Override
140   public StringBuffer getRawRecords()
141   {
142     return null;
143   }
144
145   /**
146    * Returns the URL for the client http request
147    * 
148    * @param ids
149    * @return
150    * @throws MalformedURLException
151    */
152   protected abstract URL getUrl(List<String> ids)
153           throws MalformedURLException;
154
155   /**
156    * Returns true if client uses GET method, false if it uses POST
157    * 
158    * @return
159    */
160   protected abstract boolean useGetRequest();
161
162   /**
163    * Returns the desired value for the Content-Type request header. Default is
164    * application/json, override if required to vary this.
165    * 
166    * @return
167    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
168    */
169   protected String getRequestMimeType()
170   {
171     return "application/json";
172   }
173
174   /**
175    * Return the desired value for the Accept request header. Default is
176    * application/json, override if required to vary this.
177    * 
178    * @return
179    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
180    */
181   protected String getResponseMimeType()
182   {
183     return "application/json";
184   }
185
186   /**
187    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
188    * indicates available, else false
189    * 
190    * @see http://rest.ensembl.org/documentation/info/ping
191    * @return
192    */
193   @SuppressWarnings("unchecked")
194 boolean checkEnsembl()
195   {
196     BufferedReader br = null;
197     String pingUrl = getDomain() + "/info/ping" + CONTENT_TYPE_JSON;
198     try
199     {
200       // note this format works for both ensembl and ensemblgenomes
201       // info/ping.json works for ensembl only (March 2016)
202         
203         
204         
205         
206       /*
207        * expect {"ping":1} if ok
208        * if ping takes more than 2 seconds to respond, treat as if unavailable
209        */
210       Map<String, Object> val = (Map<String, Object>) getJSON(new URL(pingUrl), null, 2 * 1000, MODE_MAP, null);
211       if (val == null)
212           return false;
213       String pingString = val.get("ping").toString();
214       return pingString != null;
215     } catch (Throwable t)
216     {
217       System.err.println(
218               "Error connecting to " + pingUrl + ": " + t.getMessage());
219     } finally
220     {
221       if (br != null)
222       {
223         try
224         {
225           br.close();
226         } catch (IOException e)
227         {
228           // ignore
229         }
230       }
231     }
232     return false;
233   }
234
235   
236   protected final static int MODE_ARRAY    = 0;
237   protected final static int MODE_MAP      = 1;
238   protected final static int MODE_ITERATOR = 2;
239   
240 //  /**
241 //   * Returns a reader to a (Json) response from the Ensembl sequence endpoint.
242 //   * If the request failed the return value may be null.
243 //   * 
244 //   * @param ids
245 //   * @return
246 //   * @throws IOException
247 // * @throws ParseException 
248 //   */
249 //  protected Object getSequenceJSON(List<String> ids, int mode)
250 //          throws IOException, ParseException
251 //  {
252 //    URL url = getUrl(ids);
253 //    return getJSON(url, ids, -1, mode);
254 //  }
255 //
256 //  /**
257 //   * Gets a reader to the HTTP response, using the default read timeout of 5
258 //   * minutes
259 //   * 
260 //   * @param url
261 //   * @param ids
262 //   * @return
263 //   * @throws IOException
264 //   */
265 //  protected BufferedReader getHttpResponse(URL url, List<String> ids)
266 //          throws IOException
267 //  {
268 //    return getHttpResponse(url, ids, DEFAULT_READ_TIMEOUT);
269 //  }
270
271   /**
272    * Sends the HTTP request and gets the response as a reader. Returns null if
273    * the HTTP response code was not 200.
274    * 
275    * @param url
276    * @param ids
277    *          written as Json POST body if more than one
278    * @param readTimeout
279    *          in milliseconds
280    * @return
281    * @throws IOException
282    */
283   private BufferedReader getHttpResponse(URL url, List<String> ids,
284           int readTimeout) throws IOException
285   {
286         if (readTimeout < 0)
287                 readTimeout = DEFAULT_READ_TIMEOUT;
288     int retriesLeft = MAX_RETRIES;
289     HttpURLConnection connection = null;
290     int responseCode = 0;
291          
292     if (Platform.isJS()) {
293           JSON.setAjax(url);
294     }
295          
296     while (retriesLeft > 0)
297     {
298       connection = tryConnection(url, ids, readTimeout);
299       responseCode = connection.getResponseCode();
300       if (responseCode == HTTP_OVERLOAD) // 429
301       {
302         retriesLeft--;
303         checkRetryAfter(connection);
304       }
305       else
306       {
307         retriesLeft = 0;
308       }
309     }
310     if (responseCode != HTTP_OK) // 200
311     {
312       /*
313        * note: a GET request for an invalid id returns an error code e.g. 415
314        * but POST request returns 200 and an empty Fasta response 
315        */
316       System.err.println("Response code " + responseCode);// + " for " + url);
317       return null;
318     }
319
320  
321     InputStream response = connection.getInputStream();
322     
323     if (Platform.isJS()) {
324         return JSON.getJSONReader(response);
325     }
326
327     // System.out.println(getClass().getName() + " took "
328     // + (System.currentTimeMillis() - now) + "ms to fetch");
329
330     return new BufferedReader(new InputStreamReader(response, "UTF-8"));
331   }
332
333   /**
334    * @param url
335    * @param ids
336    * @param readTimeout
337    * @return
338    * @throws IOException
339    * @throws ProtocolException
340    */
341   protected HttpURLConnection tryConnection(URL url, List<String> ids,
342           int readTimeout) throws IOException, ProtocolException
343   {
344     // System.out.println(System.currentTimeMillis() + " " + url);
345         
346     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
347
348     /*
349      * POST method allows multiple queries in one request; it is supported for
350      * sequence queries, but not for overlap
351      */
352     boolean multipleIds = ids != null && ids.size() > 1;
353     connection.setRequestMethod(
354             multipleIds ? HttpMethod.POST : HttpMethod.GET);
355     connection.setRequestProperty("Content-Type", getRequestMimeType());
356     connection.setRequestProperty("Accept", getResponseMimeType());
357
358     connection.setDoInput(true);
359     connection.setDoOutput(multipleIds);
360
361     connection.setUseCaches(false);
362     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
363     connection.setReadTimeout(readTimeout);
364
365     if (multipleIds)
366     {
367       writePostBody(connection, ids);
368     }
369     return connection;
370   }
371
372   /**
373    * Inspects response headers for a 'retry-after' directive, and waits for the
374    * directed period (if less than 10 seconds)
375    * 
376    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
377    * @param connection
378    */
379   void checkRetryAfter(HttpURLConnection connection)
380   {
381     String retryDelay = connection.getHeaderField("Retry-After");
382
383     // to test:
384     // retryDelay = "5";
385
386     if (retryDelay != null)
387     {
388       try
389       {
390         int retrySecs = Integer.valueOf(retryDelay);
391         if (retrySecs > 0 && retrySecs < 10)
392         {
393           System.err
394                   .println("Ensembl REST service rate limit exceeded, waiting "
395                           + retryDelay + " seconds before retrying");
396           Thread.sleep(1000 * retrySecs);
397         }
398       } catch (NumberFormatException | InterruptedException e)
399       {
400         System.err.println("Error handling Retry-After: " + e.getMessage());
401       }
402     }
403   }
404
405   /**
406    * Rechecks if Ensembl is responding, unless the last check was successful and
407    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
408    * else false. Also retrieves and saves the current version of Ensembl data
409    * and REST services at intervals.
410    * 
411    * @return
412    */
413   protected boolean isEnsemblAvailable()
414   {
415     EnsemblData info = domainData.get(getDomain());
416
417     long now = System.currentTimeMillis();
418
419     /*
420      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
421      */
422     boolean retestAvailability = (now
423             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
424     if (!info.restAvailable || retestAvailability)
425     {
426       info.restAvailable = checkEnsembl();
427       info.lastAvailableCheckTime = now;
428     }
429
430     /*
431      * refetch Ensembl versions if the recheck period has elapsed
432      */
433     boolean refetchVersion = (now
434             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
435     if (refetchVersion)
436     {
437       checkEnsemblRestVersion();
438       checkEnsemblDataVersion();
439       info.lastVersionCheckTime = now;
440     }
441
442     return info.restAvailable;
443   }
444
445   /**
446    * Constructs, writes and flushes the POST body of the request, containing the
447    * query ids in JSON format
448    * 
449    * @param connection
450    * @param ids
451    * @throws IOException
452    */
453   protected void writePostBody(HttpURLConnection connection,
454           List<String> ids) throws IOException
455   {
456     boolean first;
457     StringBuilder postBody = new StringBuilder(64);
458     postBody.append("{\"ids\":[");
459     first = true;
460     for (int i = 0, n = ids.size(); i < n; i++)
461     {
462       String id = ids.get(i);
463       if (!first)
464       {
465         postBody.append(",");
466       }
467       first = false;
468       postBody.append("\"");
469       postBody.append(id.trim());
470       postBody.append("\"");
471     }
472     postBody.append("]}");
473     byte[] thepostbody = postBody.toString().getBytes();
474     connection.setRequestProperty("Content-Length",
475             Integer.toString(thepostbody.length));
476     DataOutputStream wr = new DataOutputStream(
477             connection.getOutputStream());
478     wr.write(thepostbody);
479     wr.flush();
480     wr.close();
481   }
482
483         /**
484          * Primary access point to parsed JSON data, including the call to retrieve and
485          * parsing.
486          * 
487          * @param url     request url; if null, getUrl(ids) will be used
488          * @param ids     optional; may be null
489          * @param msDelay -1 for default delay
490          * @param mode    map, array, or array iterator
491          * @param mapKey  an optional key for an outer map 
492          * @return                a Map, List, Iterator, or null
493          * @throws IOException
494          * @throws ParseException
495          * 
496          * @author Bob Hanson 2019
497          */
498   @SuppressWarnings("unchecked")
499   protected Object getJSON(URL url, List<String> ids, int msDelay, int mode, String mapKey) throws IOException, ParseException {
500
501         if (url == null)
502            url = getUrl(ids);
503         
504         Reader br = null;
505         try {
506
507           Platform.timeCheck(null, Platform.TIME_MARK);
508                 
509       br = (url == null ? null : getHttpResponse(url, ids, msDelay));
510
511       Object ret = (br == null ? null : JSONUtils.parse(br));
512
513           Platform.timeCheck("EnsembleRestClient.getJSON " + url, Platform.TIME_MARK);
514         
515
516       if (ret != null && mapKey != null)
517             ret = ((Map<String, Object>) ret).get(mapKey);
518       if (ret == null) 
519       {
520          return null;
521       }
522       switch (mode) {
523       case MODE_ARRAY:
524       case MODE_MAP:
525         break;
526       case MODE_ITERATOR:
527         ret = ((List<Object>) ret).iterator();
528         break;
529       }
530       return ret;
531         
532     } finally
533     {
534       if (br != null)
535       {
536         try
537         {
538           br.close();
539         } catch (IOException e)
540         {
541         // ignore
542         }
543       }    
544     }
545   }
546
547
548
549   /**
550    * Fetches and checks Ensembl's REST version number
551    * 
552    * @return
553    */
554   @SuppressWarnings("unchecked")
555   private void checkEnsemblRestVersion()
556   {
557     EnsemblData info = domainData.get(getDomain());
558
559     try
560     {
561       Map<String, Object> val = (Map<String, Object>) getJSON(new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON), null, -1, MODE_MAP, null);
562       if (val == null)
563           return;
564       String version = val.get("release").toString();
565       String majorVersion = version.substring(0, version.indexOf("."));
566       String expected = info.expectedRestVersion;
567       String expectedMajorVersion = expected.substring(0,
568               expected.indexOf("."));
569       info.restMajorVersionMismatch = false;
570       try
571       {
572         /*
573          * if actual REST major version is ahead of what we expect,
574          * record this in case we want to warn the user
575          */
576         if (Float.valueOf(majorVersion) > Float
577                 .valueOf(expectedMajorVersion))
578         {
579           info.restMajorVersionMismatch = true;
580         }
581       } catch (NumberFormatException e)
582       {
583         System.err.println("Error in REST version: " + e.toString());
584       }
585
586       /*
587        * check if REST version is later than what Jalview has tested against,
588        * if so warn; we don't worry if it is earlier (this indicates Jalview has
589        * been tested in advance against the next pending REST version)
590        */
591       boolean laterVersion = StringUtils.compareVersions(version,
592               expected) == 1;
593       if (laterVersion)
594       {
595         System.err.println(String.format(
596                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
597                 getDbSource(), expected, version, REST_CHANGE_LOG));
598       }
599       info.restVersion = version;
600     } catch (Throwable t)
601     {
602       System.err.println(
603               "Error checking Ensembl REST version: " + t.getMessage());
604     }
605   }
606
607   public boolean isRestMajorVersionMismatch()
608   {
609     return domainData.get(getDomain()).restMajorVersionMismatch;
610   }
611
612   /**
613    * Fetches and checks Ensembl's data version number
614    * 
615    * @return
616    */
617   @SuppressWarnings("unchecked")
618   private void checkEnsemblDataVersion()
619   {
620     Map<String, Object> val;
621         try 
622         {
623           val = (Map<String, Object>) getJSON(
624                                   new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON), null, -1, MODE_MAP, null);
625           if (val == null)
626             return;
627           List<Object> versions = (List<Object>) val.get("releases");
628           domainData.get(getDomain()).dataVersion = versions.get(0).toString();
629         } catch (Throwable e) {//could be IOException | ParseException e) {
630           System.err.println("Error checking Ensembl data version: " + e.getMessage());
631         }
632   }
633
634   public String getEnsemblDataVersion()
635   {
636     return domainData.get(getDomain()).dataVersion;
637   }
638
639   @Override
640   public String getDbVersion()
641   {
642     return getEnsemblDataVersion();
643   }
644
645 }