b7b1985f5c9679bd5064fe71244057438a06c071
[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.io.FileParse;
24 import jalview.util.StringUtils;
25
26 import java.io.BufferedReader;
27 import java.io.DataOutputStream;
28 import java.io.IOException;
29 import java.io.InputStream;
30 import java.io.InputStreamReader;
31 import java.net.HttpURLConnection;
32 import java.net.MalformedURLException;
33 import java.net.URL;
34 import java.util.HashMap;
35 import java.util.List;
36 import java.util.Map;
37
38 import javax.ws.rs.HttpMethod;
39
40 import org.json.simple.JSONArray;
41 import org.json.simple.JSONObject;
42 import org.json.simple.parser.JSONParser;
43
44 import com.stevesoft.pat.Regex;
45
46 /**
47  * Base class for Ensembl REST service clients
48  * 
49  * @author gmcarstairs
50  */
51 abstract class EnsemblRestClient extends EnsemblSequenceFetcher
52 {
53   /*
54    * update these constants when Jalview has been checked / updated for
55    * changes to Ensembl REST API
56    * @see https://github.com/Ensembl/ensembl-rest/wiki/Change-log
57    * @see http://rest.ensembl.org/info/rest?content-type=application/json
58    */
59   private static final String LATEST_ENSEMBLGENOMES_REST_VERSION = "4.6";
60
61   private static final String LATEST_ENSEMBL_REST_VERSION = "4.7";
62
63   private static final String REST_CHANGE_LOG = "https://github.com/Ensembl/ensembl-rest/wiki/Change-log";
64
65   private static Map<String, EnsemblInfo> domainData;
66
67   // @see https://github.com/Ensembl/ensembl-rest/wiki/Output-formats
68   private static final String PING_URL = "http://rest.ensembl.org/info/ping.json";
69
70   private final static long AVAILABILITY_RETEST_INTERVAL = 10000L; // 10 seconds
71
72   private final static long VERSION_RETEST_INTERVAL = 1000L * 3600; // 1 hr
73
74   private static final Regex TRANSCRIPT_REGEX = new Regex(
75           "(ENS)([A-Z]{3}|)T[0-9]{11}$");
76
77   private static final Regex GENE_REGEX = new Regex(
78           "(ENS)([A-Z]{3}|)G[0-9]{11}$");
79
80   static
81   {
82     domainData = new HashMap<String, EnsemblInfo>();
83     domainData.put(ENSEMBL_REST, new EnsemblInfo(ENSEMBL_REST,
84             LATEST_ENSEMBL_REST_VERSION));
85     domainData.put(ENSEMBL_GENOMES_REST, new EnsemblInfo(
86             ENSEMBL_GENOMES_REST, LATEST_ENSEMBLGENOMES_REST_VERSION));
87   }
88
89   protected volatile boolean inProgress = false;
90
91   /**
92    * Default constructor to use rest.ensembl.org
93    */
94   public EnsemblRestClient()
95   {
96     this(ENSEMBL_REST);
97   }
98
99   /**
100    * Constructor given the target domain to fetch data from
101    * 
102    * @param d
103    */
104   public EnsemblRestClient(String d)
105   {
106     setDomain(d);
107   }
108
109   /**
110    * Answers true if the query matches the regular expression pattern for an
111    * Ensembl transcript stable identifier
112    * 
113    * @param query
114    * @return
115    */
116   public boolean isTranscriptIdentifier(String query)
117   {
118     return query == null ? false : TRANSCRIPT_REGEX.search(query);
119   }
120
121   /**
122    * Answers true if the query matches the regular expression pattern for an
123    * Ensembl gene stable identifier
124    * 
125    * @param query
126    * @return
127    */
128   public boolean isGeneIdentifier(String query)
129   {
130     return query == null ? false : GENE_REGEX.search(query);
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    * Return the desired value for the Content-Type request header
164    * 
165    * @param multipleIds
166    * 
167    * @return
168    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
169    */
170   protected abstract String getRequestMimeType(boolean multipleIds);
171
172   /**
173    * Return the desired value for the Accept request header
174    * 
175    * @return
176    * @see https://github.com/Ensembl/ensembl-rest/wiki/HTTP-Headers
177    */
178   protected abstract String getResponseMimeType();
179
180   /**
181    * Checks Ensembl's REST 'ping' endpoint, and returns true if response
182    * indicates available, else false
183    * 
184    * @see http://rest.ensembl.org/documentation/info/ping
185    * @return
186    */
187   private boolean checkEnsembl()
188   {
189     HttpURLConnection conn = null;
190     try
191     {
192       // note this format works for both ensembl and ensemblgenomes
193       // info/ping.json works for ensembl only (March 2016)
194       URL ping = new URL(getDomain()
195               + "/info/ping?content-type=application/json");
196
197       /*
198        * expect {"ping":1} if ok
199        */
200       BufferedReader br = getHttpResponse(ping, null);
201       JSONParser jp = new JSONParser();
202       JSONObject val = (JSONObject) jp.parse(br);
203       String pingString = val.get("ping").toString();
204       return pingString != null;
205     } catch (Throwable t)
206     {
207       System.err.println("Error connecting to " + PING_URL + ": "
208               + t.getMessage());
209     } finally
210     {
211       if (conn != null)
212       {
213         conn.disconnect();
214       }
215     }
216     return false;
217   }
218
219   /**
220    * returns a reader to a Fasta response from the Ensembl sequence endpoint
221    * 
222    * @param ids
223    * @return
224    * @throws IOException
225    */
226   protected FileParse getSequenceReader(List<String> ids)
227           throws IOException
228   {
229     URL url = getUrl(ids);
230
231     BufferedReader reader = getHttpResponse(url, ids);
232     if (reader == null)
233     {
234       // request failed
235       return null;
236     }
237     FileParse fp = new FileParse(reader, url.toString(), "HTTP_POST");
238     return fp;
239   }
240
241   /**
242    * Writes the HTTP request and gets the response as a reader.
243    * 
244    * @param url
245    * @param ids
246    *          written as Json POST body if more than one
247    * @return
248    * @throws IOException
249    *           if response code was not 200, or other I/O error
250    */
251   protected BufferedReader getHttpResponse(URL url, List<String> ids)
252           throws IOException
253   {
254     // long now = System.currentTimeMillis();
255     HttpURLConnection connection = (HttpURLConnection) url.openConnection();
256
257     /*
258      * POST method allows multiple queries in one request; it is supported for
259      * sequence queries, but not for overlap
260      */
261     boolean multipleIds = ids != null && ids.size() > 1;
262     connection.setRequestMethod(multipleIds ? HttpMethod.POST
263             : HttpMethod.GET);
264     connection.setRequestProperty("Content-Type",
265             getRequestMimeType(multipleIds));
266     connection.setRequestProperty("Accept", getResponseMimeType());
267
268     connection.setUseCaches(false);
269     connection.setDoInput(true);
270     connection.setDoOutput(multipleIds);
271
272     if (multipleIds)
273     {
274       writePostBody(connection, ids);
275     }
276
277     int responseCode = connection.getResponseCode();
278
279     if (responseCode != 200)
280     {
281       /*
282        * note: a GET request for an invalid id returns an error code e.g. 415
283        * but POST request returns 200 and an empty Fasta response 
284        */
285       System.err.println("Response code " + responseCode + " for " + url);
286       return null;
287     }
288     // get content
289     InputStream response = connection.getInputStream();
290
291     // System.out.println(getClass().getName() + " took "
292     // + (System.currentTimeMillis() - now) + "ms to fetch");
293
294     checkRateLimits(connection);
295
296     BufferedReader reader = null;
297     reader = new BufferedReader(new InputStreamReader(response, "UTF-8"));
298     return reader;
299   }
300
301   /**
302    * Inspect response headers for any sign of server overload and respect any
303    * 'retry-after' directive
304    * 
305    * @see https://github.com/Ensembl/ensembl-rest/wiki/Rate-Limits
306    * @param connection
307    */
308   void checkRateLimits(HttpURLConnection connection)
309   {
310     // number of requests allowed per time interval:
311     String limit = connection.getHeaderField("X-RateLimit-Limit");
312     // length of quota time interval in seconds:
313     // String period = connection.getHeaderField("X-RateLimit-Period");
314     // seconds remaining until usage quota is reset:
315     String reset = connection.getHeaderField("X-RateLimit-Reset");
316     // number of requests remaining from quota for current period:
317     String remaining = connection.getHeaderField("X-RateLimit-Remaining");
318     // number of seconds to wait before retrying (if remaining == 0)
319     String retryDelay = connection.getHeaderField("Retry-After");
320
321     // to test:
322     // retryDelay = "5";
323
324     EnsemblInfo info = domainData.get(getDomain());
325     if (retryDelay != null)
326     {
327       System.err.println("Ensembl REST service rate limit exceeded, wait "
328               + retryDelay + " seconds before retrying");
329       try
330       {
331         info.retryAfter = System.currentTimeMillis()
332                 + (1000 * Integer.valueOf(retryDelay));
333       } catch (NumberFormatException e)
334       {
335         System.err.println("Unexpected value for Retry-After: "
336                 + retryDelay);
337       }
338     }
339     else
340     {
341       info.retryAfter = 0;
342       // debug:
343       // System.out.println(String.format(
344       // "%s Ensembl requests remaining of %s (reset in %ss)",
345       // remaining, limit, reset));
346     }
347   }
348
349   /**
350    * Rechecks if Ensembl is responding, unless the last check was successful and
351    * the retest interval has not yet elapsed. Returns true if Ensembl is up,
352    * else false. Also retrieves and saves the current version of Ensembl data
353    * and REST services at intervals.
354    * 
355    * @return
356    */
357   protected boolean isEnsemblAvailable()
358   {
359     EnsemblInfo info = domainData.get(getDomain());
360
361     long now = System.currentTimeMillis();
362
363     /*
364      * check if we are waiting for 'Retry-After' to expire
365      */
366     if (info.retryAfter > now)
367     {
368       System.err.println("Still " + (1 + (info.retryAfter - now) / 1000)
369               + " secs to wait before retrying Ensembl");
370       return false;
371     }
372     else
373     {
374       info.retryAfter = 0;
375     }
376
377     /*
378      * recheck if Ensembl is up if it was down, or the recheck period has elapsed
379      */
380     boolean retestAvailability = (now - info.lastAvailableCheckTime) > AVAILABILITY_RETEST_INTERVAL;
381     if (!info.restAvailable || retestAvailability)
382     {
383       info.restAvailable = checkEnsembl();
384       info.lastAvailableCheckTime = now;
385     }
386
387     /*
388      * refetch Ensembl versions if the recheck period has elapsed
389      */
390     boolean refetchVersion = (now - info.lastVersionCheckTime) > VERSION_RETEST_INTERVAL;
391     if (refetchVersion)
392     {
393       checkEnsemblRestVersion();
394       checkEnsemblDataVersion();
395       info.lastVersionCheckTime = now;
396     }
397
398     return info.restAvailable;
399   }
400
401   /**
402    * Constructs, writes and flushes the POST body of the request, containing the
403    * query ids in JSON format
404    * 
405    * @param connection
406    * @param ids
407    * @throws IOException
408    */
409   protected void writePostBody(HttpURLConnection connection,
410           List<String> ids) throws IOException
411   {
412     boolean first;
413     StringBuilder postBody = new StringBuilder(64);
414     postBody.append("{\"ids\":[");
415     first = true;
416     for (String id : ids)
417     {
418       if (!first)
419       {
420         postBody.append(",");
421       }
422       first = false;
423       postBody.append("\"");
424       postBody.append(id.trim());
425       postBody.append("\"");
426     }
427     postBody.append("]}");
428     byte[] thepostbody = postBody.toString().getBytes();
429     connection.setRequestProperty("Content-Length",
430             Integer.toString(thepostbody.length));
431     DataOutputStream wr = new DataOutputStream(connection.getOutputStream());
432     wr.write(thepostbody);
433     wr.flush();
434     wr.close();
435   }
436
437   /**
438    * Fetches and checks Ensembl's REST version number
439    * 
440    * @return
441    */
442   private void checkEnsemblRestVersion()
443   {
444     EnsemblInfo info = domainData.get(getDomain());
445
446     JSONParser jp = new JSONParser();
447     URL url = null;
448     try
449     {
450       url = new URL(getDomain()
451               + "/info/rest?content-type=application/json");
452       BufferedReader br = getHttpResponse(url, null);
453       JSONObject val = (JSONObject) jp.parse(br);
454       String version = val.get("release").toString();
455       String majorVersion = version.substring(0, version.indexOf("."));
456       String expected = info.expectedRestVersion;
457       String expectedMajorVersion = expected.substring(0,
458               expected.indexOf("."));
459       info.restMajorVersionMismatch = false;
460       try
461       {
462         /*
463          * if actual REST major version is ahead of what we expect,
464          * record this in case we want to warn the user
465          */
466         if (Float.valueOf(majorVersion) > Float
467                 .valueOf(expectedMajorVersion))
468         {
469           info.restMajorVersionMismatch = true;
470         }
471       } catch (NumberFormatException e)
472       {
473         System.err.println("Error in REST version: " + e.toString());
474       }
475
476       /*
477        * check if REST version is later than what Jalview has tested against,
478        * if so warn; we don't worry if it is earlier (this indicates Jalview has
479        * been tested in advance against the next pending REST version)
480        */
481       boolean laterVersion = StringUtils.compareVersions(version, expected) == 1;
482       if (laterVersion)
483       {
484         System.err.println(String.format(
485                 "Expected %s REST version %s but found %s, see %s",
486                 getDbSource(), expected, version, REST_CHANGE_LOG));
487       }
488       info.restVersion = version;
489     } catch (Throwable t)
490     {
491       System.err.println("Error checking Ensembl REST version: "
492               + t.getMessage());
493     }
494   }
495
496   public boolean isRestMajorVersionMismatch()
497   {
498     return domainData.get(getDomain()).restMajorVersionMismatch;
499   }
500
501   /**
502    * Fetches and checks Ensembl's data version number
503    * 
504    * @return
505    */
506   private void checkEnsemblDataVersion()
507   {
508     JSONParser jp = new JSONParser();
509     URL url = null;
510     try
511     {
512       url = new URL(getDomain()
513               + "/info/data?content-type=application/json");
514       BufferedReader br = getHttpResponse(url, null);
515       JSONObject val = (JSONObject) jp.parse(br);
516       JSONArray versions = (JSONArray) val.get("releases");
517       domainData.get(getDomain()).dataVersion = versions.get(0).toString();
518     } catch (Throwable t)
519     {
520       System.err.println("Error checking Ensembl data version: "
521               + t.getMessage());
522     }
523   }
524
525   public String getEnsemblDataVersion()
526   {
527     return domainData.get(getDomain()).dataVersion;
528   }
529
530   @Override
531   public String getDbVersion()
532   {
533     return getEnsemblDataVersion();
534   }
535
536 }