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