JSON refactoring
[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 (String id : ids)
461     {
462       if (!first)
463       {
464         postBody.append(",");
465       }
466       first = false;
467       postBody.append("\"");
468       postBody.append(id.trim());
469       postBody.append("\"");
470     }
471     postBody.append("]}");
472     byte[] thepostbody = postBody.toString().getBytes();
473     connection.setRequestProperty("Content-Length",
474             Integer.toString(thepostbody.length));
475     DataOutputStream wr = new DataOutputStream(
476             connection.getOutputStream());
477     wr.write(thepostbody);
478     wr.flush();
479     wr.close();
480   }
481
482         /**
483          * Primary access point to parsed JSON data, including the call to retrieve and
484          * parsing.
485          * 
486          * @param url     request url; if null, getUrl(ids) will be used
487          * @param ids     optional; may be null
488          * @param msDelay -1 for default delay
489          * @param mode    map, array, or array iterator
490          * @param mapKey  an optional key for an outer map 
491          * @return                a Map, List, Iterator, or null
492          * @throws IOException
493          * @throws ParseException
494          * 
495          * @author Bob Hanson 2019
496          */
497   @SuppressWarnings("unchecked")
498   protected Object getJSON(URL url, List<String> ids, int msDelay, int mode, String mapKey) throws IOException, ParseException {
499
500         if (url == null)
501            url = getUrl(ids);
502         
503         Platform.timeCheck("EnsembleRestClient.getJSON0 " + url, Platform.TIME_MARK);
504         
505         Reader br = null;
506         try {
507       br = (url == null ? null : getHttpResponse(url, ids, msDelay));
508
509       Platform.timeCheck("EnsembleRestClient.getJSON1 parsing... ", Platform.TIME_MARK);
510         
511       Object ret = (br == null ? null : JSONUtils.parse(br));
512
513       Platform.timeCheck("EnsembleRestClient.getJSON2 ...done ", Platform.TIME_MARK);
514
515       if (ret != null && mapKey != null)
516             ret = ((Map<String, Object>) ret).get(mapKey);
517       if (ret == null) 
518       {
519          return null;
520       }
521       switch (mode) {
522       case MODE_ARRAY:
523       case MODE_MAP:
524         break;
525       case MODE_ITERATOR:
526         ret = ((List<Object>) ret).iterator();
527         break;
528       }
529       return ret;
530         
531     } finally
532     {
533       if (br != null)
534       {
535         try
536         {
537           br.close();
538         } catch (IOException e)
539         {
540         // ignore
541         }
542       }    
543     }
544   }
545
546
547
548   /**
549    * Fetches and checks Ensembl's REST version number
550    * 
551    * @return
552    */
553   @SuppressWarnings("unchecked")
554   private void checkEnsemblRestVersion()
555   {
556     EnsemblData info = domainData.get(getDomain());
557
558     try
559     {
560       Map<String, Object> val = (Map<String, Object>) getJSON(new URL(getDomain() + "/info/rest" + CONTENT_TYPE_JSON), null, -1, MODE_MAP, null);
561       if (val == null)
562           return;
563       String version = val.get("release").toString();
564       String majorVersion = version.substring(0, version.indexOf("."));
565       String expected = info.expectedRestVersion;
566       String expectedMajorVersion = expected.substring(0,
567               expected.indexOf("."));
568       info.restMajorVersionMismatch = false;
569       try
570       {
571         /*
572          * if actual REST major version is ahead of what we expect,
573          * record this in case we want to warn the user
574          */
575         if (Float.valueOf(majorVersion) > Float
576                 .valueOf(expectedMajorVersion))
577         {
578           info.restMajorVersionMismatch = true;
579         }
580       } catch (NumberFormatException e)
581       {
582         System.err.println("Error in REST version: " + e.toString());
583       }
584
585       /*
586        * check if REST version is later than what Jalview has tested against,
587        * if so warn; we don't worry if it is earlier (this indicates Jalview has
588        * been tested in advance against the next pending REST version)
589        */
590       boolean laterVersion = StringUtils.compareVersions(version,
591               expected) == 1;
592       if (laterVersion)
593       {
594         System.err.println(String.format(
595                 "EnsemblRestClient expected %s REST version %s but found %s, see %s",
596                 getDbSource(), expected, version, REST_CHANGE_LOG));
597       }
598       info.restVersion = version;
599     } catch (Throwable t)
600     {
601       System.err.println(
602               "Error checking Ensembl REST version: " + t.getMessage());
603     }
604   }
605
606   public boolean isRestMajorVersionMismatch()
607   {
608     return domainData.get(getDomain()).restMajorVersionMismatch;
609   }
610
611   /**
612    * Fetches and checks Ensembl's data version number
613    * 
614    * @return
615    */
616   @SuppressWarnings("unchecked")
617   private void checkEnsemblDataVersion()
618   {
619     Map<String, Object> val;
620         try 
621         {
622           val = (Map<String, Object>) getJSON(
623                                   new URL(getDomain() + "/info/data" + CONTENT_TYPE_JSON), null, -1, MODE_MAP, null);
624           if (val == null)
625             return;
626           List<Object> versions = (List<Object>) val.get("releases");
627           domainData.get(getDomain()).dataVersion = versions.get(0).toString();
628         } catch (Throwable e) {//could be IOException | ParseException e) {
629           System.err.println("Error checking Ensembl data version: " + e.getMessage());
630         }
631   }
632
633   public String getEnsemblDataVersion()
634   {
635     return domainData.get(getDomain()).dataVersion;
636   }
637
638   @Override
639   public String getDbVersion()
640   {
641     return getEnsemblDataVersion();
642   }
643
644 }