59c568b63e6b756de0005aca29c599d01b284d3b
[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 = "13.0";
73
74   private static final String LATEST_ENSEMBL_REST_VERSION = "13.0";
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       System.err.println(
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       System.err.println("Response code " + responseCode);// + " for " + url);
317       return null;
318     }
319
320     InputStream response = connection.getInputStream();
321
322     // Platform.timeCheck(null, Platform.TIME_MARK);
323     Object ret = Platform.parseJSON(response);
324     // Platform.timeCheck("EnsemblRestClient.getJSON " + url,
325     // Platform.TIME_MARK);
326
327     return ret;
328   }
329
330   /**
331    * @param url
332    * @param ids
333    * @param readTimeout
334    * @return
335    * @throws IOException
336    * @throws ProtocolException
337    */
338   protected HttpURLConnection tryConnection(URL url, List<String> ids,
339           int readTimeout) throws IOException, ProtocolException
340   {
341     // System.out.println(System.currentTimeMillis() + " " + url);
342
343     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
344
345     /*
346      * POST method allows multiple queries in one request; it is supported for
347      * sequence queries, but not for overlap
348      */
349     boolean multipleIds = ids != null && ids.size() > 1;
350     connection.setRequestMethod(
351             multipleIds ? HttpMethod.POST : HttpMethod.GET);
352     connection.setRequestProperty("Content-Type", getRequestMimeType());
353     connection.setRequestProperty("Accept", getResponseMimeType());
354
355     connection.setDoInput(true);
356     connection.setDoOutput(multipleIds);
357
358     connection.setUseCaches(false);
359     connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
360     connection.setReadTimeout(readTimeout);
361
362     if (multipleIds)
363     {
364       writePostBody(connection, ids);
365     }
366     return connection;
367   }
368
369   /**
370    * Inspects response headers for a 'retry-after' directive, and waits for the
371    * directed period (if less than 10 seconds)
372    * 
373    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
374    * @param connection
375    */
376   void checkRetryAfter(HttpURLConnection connection)
377   {
378     String retryDelay = connection.getHeaderField("Retry-After");
379
380     // to test:
381     // retryDelay = "5";
382
383     if (retryDelay != null)
384     {
385       try
386       {
387         int retrySecs = Integer.valueOf(retryDelay);
388         if (retrySecs > 0 && retrySecs < 10)
389         {
390           System.err.println(
391                   "Ensembl REST service rate limit exceeded, waiting "
392                           + retryDelay + " seconds before retrying");
393           Thread.sleep(1000 * retrySecs);
394         }
395       } catch (NumberFormatException | InterruptedException e)
396       {
397         System.err.println("Error handling Retry-After: " + e.getMessage());
398       }
399     }
400   }
401
402   /**
403    * Rechecks if Ensembl is responding, unless the last check was successful and
404    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
405    * else false. Also retrieves and saves the current version of Ensembl data
406    * and REST services at intervals.
407    * 
408    * @return
409    */
410   protected boolean isEnsemblAvailable()
411   {
412     EnsemblData info = domainData.get(getDomain());
413
414     long now = System.currentTimeMillis();
415
416     /*
417      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
418      */
419     boolean retestAvailability = (now
420             - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
421     if (!info.restAvailable || retestAvailability)
422     {
423       info.restAvailable = checkEnsembl();
424       info.lastAvailableCheckTime = now;
425     }
426
427     /*
428      * refetch Ensembl versions if the recheck period has elapsed
429      */
430     boolean refetchVersion = (now
431             - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
432     if (refetchVersion)
433     {
434       checkEnsemblRestVersion();
435       checkEnsemblDataVersion();
436       info.lastVersionCheckTime = now;
437     }
438
439     return info.restAvailable;
440   }
441
442   /**
443    * Constructs, writes and flushes the POST body of the request, containing the
444    * query ids in JSON format
445    * 
446    * @param connection
447    * @param ids
448    * @throws IOException
449    */
450   protected void writePostBody(HttpURLConnection connection,
451           List<String> ids) throws IOException
452   {
453     boolean first;
454     StringBuilder postBody = new StringBuilder(64);
455     postBody.append("{\"ids\":[");
456     first = true;
457     for (int i = 0, n = ids.size(); i < n; i++)
458     {
459       String id = ids.get(i);
460       if (!first)
461       {
462         postBody.append(",");
463       }
464       first = false;
465       postBody.append("\"");
466       postBody.append(id.trim());
467       postBody.append("\"");
468     }
469     postBody.append("]}");
470     byte[] thepostbody = postBody.toString().getBytes();
471     connection.setRequestProperty("Content-Length",
472             Integer.toString(thepostbody.length));
473     DataOutputStream wr = new DataOutputStream(
474             connection.getOutputStream());
475     wr.write(thepostbody);
476     wr.flush();
477     wr.close();
478   }
479
480   /**
481    * Primary access point to parsed JSON data, including the call to retrieve
482    * and parsing.
483    * 
484    * @param url
485    *          request url; if null, getUrl(ids) will be used
486    * @param ids
487    *          optional; may be null
488    * @param msDelay
489    *          -1 for default delay
490    * @param mode
491    *          map, array, or array iterator
492    * @param mapKey
493    *          an optional key for an outer map
494    * @return a Map, List, Iterator, or null
495    * @throws IOException
496    * @throws ParseException
497    * 
498    * @author Bob Hanson 2019
499    */
500   @SuppressWarnings("unchecked")
501   protected Object getJSON(URL url, List<String> ids, int msDelay, int mode,
502           String mapKey) throws IOException, ParseException
503   {
504     if (url == null)
505     {
506       url = getUrl(ids);
507     }
508
509     Object json = (url == null ? null : getJSON(url, ids, msDelay));
510
511     if (json != null && mapKey != null)
512     {
513       json = ((Map<String, Object>) json).get(mapKey);
514     }
515     if (json == null)
516     {
517       return null;
518     }
519     switch (mode)
520     {
521     case MODE_ARRAY:
522     case MODE_MAP:
523       break;
524     case MODE_ITERATOR:
525       json = ((List<Object>) json).iterator();
526       break;
527     }
528     return json;
529   }
530
531   /**
532    * Fetches and checks Ensembl's REST version number
533    * 
534    * @return
535    */
536   @SuppressWarnings("unchecked")
537   private void checkEnsemblRestVersion()
538   {
539     EnsemblData info = domainData.get(getDomain());
540
541     try
542     {
543       Map<String, Object> val = (Map<String, Object>) getJSON(
544               new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON), null,
545               -1, MODE_MAP, null);
546       if (val == null)
547       {
548         return;
549       }
550       String version = val.get("release").toString();
551       String majorVersion = version.substring(0, version.indexOf("."));
552       String expected = info.expectedRestVersion;
553       String expectedMajorVersion = expected.substring(0,
554               expected.indexOf("."));
555       info.restMajorVersionMismatch = false;
556       try
557       {
558         /*
559          * if actual REST major version is ahead of what we expect,
560          * record this in case we want to warn the user
561          */
562         if (Float.valueOf(majorVersion) > Float
563                 .valueOf(expectedMajorVersion))
564         {
565           info.restMajorVersionMismatch = true;
566         }
567       } catch (NumberFormatException e)
568       {
569         System.err.println("Error in REST version: " + e.toString());
570       }
571
572       /*
573        * check if REST version is later than what Jalview has tested against,
574        * if so warn; we don't worry if it is earlier (this indicates Jalview has
575        * been tested in advance against the next pending REST version)
576        */
577       boolean laterVersion = StringUtils.compareVersions(version,
578               expected) == 1;
579       if (laterVersion)
580       {
581         System.err.println(String.format(
582                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
583                 getDbSource(), expected, version, REST_CHANGE_LOG));
584       }
585       info.restVersion = version;
586     } catch (Throwable t)
587     {
588       System.err.println(
589               "Error checking Ensembl REST version: " + t.getMessage());
590     }
591   }
592
593   public boolean isRestMajorVersionMismatch()
594   {
595     return domainData.get(getDomain()).restMajorVersionMismatch;
596   }
597
598   /**
599    * Fetches and checks Ensembl's data version number
600    * 
601    * @return
602    */
603   @SuppressWarnings("unchecked")
604   private void checkEnsemblDataVersion()
605   {
606     Map<String, Object> val;
607     try
608     {
609       val = (Map<String, Object>) getJSON(
610               new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON), null,
611               -1, MODE_MAP, null);
612       if (val == null)
613       {
614         return;
615       }
616       List<Object> versions = (List<Object>) val.get("releases");
617       domainData.get(getDomain()).dataVersion = versions.get(0).toString();
618     } catch (Throwable e)
619     {// could be IOException | ParseException e) {
620       System.err.println(
621               "Error checking Ensembl data version: " + e.getMessage());
622     }
623   }
624
625   public String getEnsemblDataVersion()
626   {
627     return domainData.get(getDomain()).dataVersion;
628   }
629
630   @Override
631   public String getDbVersion()
632   {
633     return getEnsemblDataVersion();
634   }
635
636 }