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