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